Restore due date on task list items

Replace toast with snackbar with an undo action
This commit is contained in:
Geoffroy Bonneville
2025-10-10 15:43:36 -04:00
parent c57210494a
commit c579a5d252
12 changed files with 157 additions and 88 deletions

View File

@@ -1,6 +1,5 @@
package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -10,7 +9,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.presentation.viewmodel.DueTodayViewModel
@@ -31,7 +29,6 @@ fun DueTodayTasksScreen(
Text("Nothing due today !")
}
} else {
val context = LocalContext.current
LazyColumn(
modifier = modifier.padding(8.dp)
) {
@@ -39,15 +36,8 @@ fun DueTodayTasksScreen(
TaskItemScreen(
modifier = Modifier.animateItem(),
task = 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()
}
onSwipeLeft = { viewModel.updateTaskDone(task.id!!) },
onSwipeRight = { viewModel.deleteTask(task.id!!) }
)
}
}

View File

@@ -25,6 +25,10 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -53,7 +57,6 @@ import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -63,7 +66,6 @@ fun MainScreen(
) {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
if (viewModel.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -88,7 +90,7 @@ fun MainScreen(
},
drawerState = drawerState
) {
AppContent(viewModel = viewModel, navController = navController, scope = scope, drawerState = drawerState)
AppContent(viewModel = viewModel, navController = navController, drawerState = drawerState)
}
}
@@ -97,9 +99,11 @@ fun AppContent(
modifier : Modifier = Modifier,
viewModel: MainViewModel,
navController: NavHostController,
scope: CoroutineScope,
drawerState: DrawerState
) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.uiEventBus.events.collectLatest { event ->
when (event) {
@@ -109,13 +113,24 @@ fun AppContent(
}
is UiEvent.NavigateBack -> navController.popBackStack()
is UiEvent.EditTask -> { viewModel.showTaskSheet = true }
else -> {}
is UiEvent.ShowUndoSnackbar -> {
val result = snackbarHostState.showSnackbar(
message = event.message,
actionLabel = "Undo",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
event.undoAction()
}
}
else -> Unit
}
}
}
Scaffold(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(viewModel.currentDestination.title) },

View File

@@ -75,11 +75,9 @@ fun RecycleBinScreen(
TaskItemScreen(
modifier = Modifier.animateItem(),
task = item.task,
onSwipeLeft = {
viewModel.restore(item.task.id!!)
Toast.makeText(context, "Task restored", Toast.LENGTH_SHORT).show()
},
onSwipeLeft = { viewModel.restore(item.task.id!!) },
onSwipeRight = {
// TODO: add confirmation dialog
viewModel.deleteForever(item.task.id!!)
Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show()
}

View File

@@ -123,7 +123,7 @@ fun TaskItemScreen(
)
// Due date badge
viewModel.dueDateText?.let { dueMillis ->
viewModel.dueDate?.let { dueMillis ->
Badge(
modifier = Modifier
.align(
@@ -134,7 +134,7 @@ fun TaskItemScreen(
) {
Text(
modifier = Modifier.padding(start = 1.dp, end = 1.dp),
text = viewModel.dueDateText,
text = viewModel.dueDate!!,
color = if (viewModel.isOverdue) Color.White else MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodySmall
)

View File

@@ -1,7 +1,7 @@
package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -9,26 +9,39 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
@Composable
fun TaskListScreen(
modifier: Modifier = Modifier,
viewModel: TaskListViewModel = hiltViewModel<TaskListViewModel>(),
) {
val tasks = viewModel.tasks
if (tasks.isEmpty()) {
// Placeholder when recycle bin is empty
Column (
modifier = modifier.fillMaxSize().padding(10.dp),
Arrangement.Center
) {
Text("Nothing here!", modifier.fillMaxWidth(), textAlign = TextAlign.Center)
Text("Add tasks with the Create Task button", modifier.fillMaxWidth(), textAlign = TextAlign.Center)
}
return
}
// Split tasks into active and done
val (active, done) = remember(tasks) {
tasks.partition { !it.isDone }
}
val context = LocalContext.current
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
@@ -42,14 +55,8 @@ fun TaskListScreen(
TaskItemScreen(
modifier = Modifier.animateItem(),
task = task,
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!, true)
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()
}
onSwipeLeft = { viewModel.updateTaskDone(task.id!!, true) },
onSwipeRight = { viewModel.deleteTask(task.id!!) }
)
}
@@ -72,14 +79,8 @@ fun TaskListScreen(
TaskItemScreen(
modifier = Modifier.animateItem(),
task = task,
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!, false)
Toast.makeText(context, "Task in progress", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteTask(task.id!!)
Toast.makeText(context, "Task moved to recycle bin", Toast.LENGTH_SHORT).show()
},
onSwipeLeft = { viewModel.updateTaskDone(task.id!!, false) },
onSwipeRight = { viewModel.deleteTask(task.id!!) },
)
}

View File

@@ -5,9 +5,11 @@ import com.wismna.geoffroy.donext.domain.model.Task
sealed class UiEvent {
data class Navigate(val route: String) : UiEvent()
data object NavigateBack : UiEvent()
data class ShowSnackbar(val message: String) : UiEvent()
data class EditTask(val task: Task) : UiEvent()
data class CreateNewTask(val taskListId: Long) : UiEvent()
data object CloseTask : UiEvent()
data class ShowUndoSnackbar(
val message: String,
val undoAction: () -> Unit
) : UiEvent()
}

View File

@@ -9,6 +9,8 @@ import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -17,16 +19,17 @@ import javax.inject.Inject
@HiltViewModel
class DueTodayViewModel @Inject constructor(
getDueTodayTasks: GetDueTodayTasksUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
private val toggleTaskDone: ToggleTaskDoneUseCase
getDueTodayTasksUseCase: GetDueTodayTasksUseCase,
private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase,
private val toggleTaskDoneUseCase: ToggleTaskDoneUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() {
var dueTodayTasks by mutableStateOf<List<Task>>(emptyList())
private set
init {
getDueTodayTasks()
getDueTodayTasksUseCase()
.onEach { tasks ->
dueTodayTasks = tasks
}
@@ -35,12 +38,35 @@ class DueTodayViewModel @Inject constructor(
fun updateTaskDone(taskId: Long) {
viewModelScope.launch {
toggleTaskDone(taskId, true)
toggleTaskDoneUseCase(taskId, true)
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = "Task done",
undoAction = {
viewModelScope.launch {
toggleTaskDoneUseCase(taskId, false)
}
}
)
)
}
}
fun deleteTask(taskId: Long) {
viewModelScope.launch {
toggleTaskDeleted(taskId, true)
toggleTaskDeletedUseCase(taskId, true)
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = "Task moved to trash",
undoAction = {
viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, false)
}
}
)
)
}
}
}

View File

@@ -46,10 +46,10 @@ class MainViewModel @Inject constructor(
AppDestination.ManageLists +
AppDestination.RecycleBin +
AppDestination.DueTodayList
isLoading = false
if (startDestination == AppDestination.ManageLists && destinations.isNotEmpty()) {
startDestination = destinations.first()
}
isLoading = false
}
.launchIn(viewModelScope)
}

View File

@@ -6,7 +6,6 @@ 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.AppDestination
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase

View File

@@ -10,6 +10,8 @@ import com.wismna.geoffroy.donext.domain.usecase.EmptyRecycleBinUseCase
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 com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -18,10 +20,11 @@ import javax.inject.Inject
@HiltViewModel
class RecycleBinViewModel @Inject constructor(
private val getDeletedTasks: GetDeletedTasksUseCase,
private val restoreTask: ToggleTaskDeletedUseCase,
private val permanentlyDeleteTask: PermanentlyDeleteTaskUseCase,
private val emptyRecycleBinUseCase: EmptyRecycleBinUseCase
private val getDeletedTasksUseCase: GetDeletedTasksUseCase,
private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase,
private val permanentlyDeleteTaskUseCase: PermanentlyDeleteTaskUseCase,
private val emptyRecycleBinUseCase: EmptyRecycleBinUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() {
var deletedTasks by mutableStateOf<List<TaskWithListName>>(emptyList())
@@ -32,7 +35,7 @@ class RecycleBinViewModel @Inject constructor(
}
fun loadDeletedTasks() {
getDeletedTasks()
getDeletedTasksUseCase()
.onEach { tasks ->
deletedTasks = tasks
}
@@ -41,14 +44,25 @@ class RecycleBinViewModel @Inject constructor(
fun restore(taskId: Long) {
viewModelScope.launch {
restoreTask(taskId, false)
toggleTaskDeletedUseCase(taskId, false)
loadDeletedTasks()
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = "Task restored",
undoAction = {
viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, true)
}
}
)
)
}
}
fun deleteForever(taskId: Long) {
viewModelScope.launch {
permanentlyDeleteTask(taskId)
permanentlyDeleteTaskUseCase(taskId)
loadDeletedTasks()
}
}

View File

@@ -24,41 +24,25 @@ class TaskItemViewModel @Inject constructor(
var id: Long? = null
var name: String? = null
var description: String? = null
var dueDate: Long? = null
var dueDate: String? = null
var isDone: Boolean = false
var isDeleted: Boolean = false
var priority: Priority = Priority.NORMAL
var isOverdue: Boolean = false
val today: LocalDate = LocalDate.now(ZoneId.systemDefault())
val isOverdue: Boolean = dueDate?.let { millis ->
val dueDate = millis.toLocalDate()
dueDate.isBefore(today)
} ?: false
val dueDateText: String? = dueDate?.let { formatDueDate(it) }
private fun formatDueDate(dueMillis: Long): String {
val dueDate = dueMillis.toLocalDate()
return when {
dueDate.isEqual(today) -> "Today"
dueDate.isEqual(today.plusDays(1)) -> "Tomorrow"
dueDate.isEqual(today.minusDays(1)) -> "Yesterday"
dueDate.isAfter(today) && dueDate.isBefore(today.plusDays(7)) ->
dueDate.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault())
else ->
dueDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()))
}
}
fun populateTask(task: Task) {
id = task.id!!
name = task.name
description = task.description
dueDate = task.dueDate?.let { formatDueDate(it) }
isDone = task.isDone
isDeleted = task.isDeleted
priority = task.priority
isOverdue = task.dueDate?.let { millis ->
val dueDate = millis.toLocalDate()
dueDate.isBefore(today)
} ?: false
}
fun onTaskClicked(task: Task) {
@@ -66,4 +50,19 @@ class TaskItemViewModel @Inject constructor(
uiEventBus.send(UiEvent.EditTask(task))
}
}
private fun formatDueDate(dueMillis: Long): String {
val dueDateLocal = dueMillis.toLocalDate()
return when {
dueDateLocal.isEqual(today) -> "Today"
dueDateLocal.isEqual(today.plusDays(1)) -> "Tomorrow"
dueDateLocal.isEqual(today.minusDays(1)) -> "Yesterday"
dueDateLocal.isAfter(today) && dueDateLocal.isBefore(today.plusDays(7)) ->
dueDateLocal.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault())
else ->
dueDateLocal.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()))
}
}
}

View File

@@ -10,6 +10,8 @@ import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -18,10 +20,11 @@ import javax.inject.Inject
@HiltViewModel
class TaskListViewModel @Inject constructor(
getTasks: GetTasksForListUseCase,
savedStateHandle: SavedStateHandle,
private val toggleTaskDone: ToggleTaskDoneUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
getTasksUseCase: GetTasksForListUseCase,
private val toggleTaskDoneUseCase: ToggleTaskDoneUseCase,
private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() {
var tasks by mutableStateOf<List<Task>>(emptyList())
@@ -32,7 +35,7 @@ class TaskListViewModel @Inject constructor(
private val taskListId: Long = checkNotNull(savedStateHandle["taskListId"])
init {
getTasks(taskListId)
getTasksUseCase(taskListId)
.onEach { list ->
tasks = list
isLoading = false
@@ -42,12 +45,34 @@ class TaskListViewModel @Inject constructor(
fun updateTaskDone(taskId: Long, isDone: Boolean) {
viewModelScope.launch {
toggleTaskDone(taskId, isDone)
toggleTaskDoneUseCase(taskId, isDone)
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = "Task done",
undoAction = {
viewModelScope.launch {
toggleTaskDoneUseCase(taskId, !isDone)
}
}
)
)
}
}
fun deleteTask(taskId: Long) {
viewModelScope.launch {
toggleTaskDeleted(taskId, true)
toggleTaskDeletedUseCase(taskId, true)
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = "Task moved to trash",
undoAction = {
viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, false)
}
}
)
)
}
}
}