Finally fix the double navigation issue event

Dialogs now survive screen rotation
Improve empty screens placeholders
This commit is contained in:
Geoffroy Bonneville
2025-10-28 22:15:36 -04:00
parent c424d883ba
commit 60cd307a87
7 changed files with 95 additions and 37 deletions

View File

@@ -1,10 +1,16 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -26,7 +32,14 @@ fun DueTodayTasksScreen(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Nothing due today !")
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.CalendarToday,
contentDescription = "Due today background icon",
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.secondary)
Text("Nothing due today !", color = MaterialTheme.colorScheme.secondary)
}
}
} else {
LazyColumn(

View File

@@ -206,7 +206,6 @@ fun AppContent(
navController.navigate(event.route)
}
is UiEvent.NavigateBack -> navController.popBackStack()
is UiEvent.EditTask -> { viewModel.showTaskSheet = true }
is UiEvent.ShowUndoSnackbar -> {
val result = snackbarHostState.showSnackbar(
message = event.message,
@@ -221,6 +220,15 @@ fun AppContent(
}
}
}
LaunchedEffect(Unit) {
viewModel.uiEventBus.stickyEvents.collect { event ->
when (event) {
is UiEvent.EditTask -> { viewModel.showTaskSheet = true }
else -> Unit
}
}
}
Scaffold(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent,

View File

@@ -2,12 +2,15 @@ package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
@@ -19,9 +22,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -29,6 +29,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.wismna.geoffroy.donext.presentation.viewmodel.RecycleBinViewModel
@Composable
@@ -37,6 +38,7 @@ fun RecycleBinScreen(
viewModel: RecycleBinViewModel = hiltViewModel(),
) {
val tasks = viewModel.deletedTasks
val taskToDelete by viewModel.taskToDeleteFlow.collectAsStateWithLifecycle()
if (tasks.isEmpty()) {
// Placeholder when recycle bin is empty
@@ -44,7 +46,15 @@ fun RecycleBinScreen(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Recycle Bin is empty")
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.DeleteOutline,
contentDescription = "Recycle bin background icon",
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.secondary
)
Text("Recycle Bin is empty", color = MaterialTheme.colorScheme.secondary)
}
}
return
}
@@ -52,7 +62,7 @@ fun RecycleBinScreen(
val grouped = tasks.groupBy { it.listName }
val context = LocalContext.current
if (viewModel.taskToDelete != null) {
if (taskToDelete != null) {
AlertDialog(
onDismissRequest = { viewModel.onCancelDelete() },
title = { Text("Delete task") },
@@ -114,10 +124,10 @@ fun RecycleBinScreen(
@Composable
fun EmptyRecycleBinAction(viewModel: RecycleBinViewModel = hiltViewModel()) {
val isEmpty = viewModel.deletedTasks.isEmpty()
var showConfirmDialog by remember { mutableStateOf(false) }
val emptyRecycleBin by viewModel.emptyRecycleBinFlow.collectAsStateWithLifecycle()
IconButton(
onClick = { showConfirmDialog = true },
onClick = { viewModel.onEmptyRecycleBinRequest() },
enabled = !isEmpty) {
Icon(
Icons.Default.DeleteSweep,
@@ -127,9 +137,9 @@ fun EmptyRecycleBinAction(viewModel: RecycleBinViewModel = hiltViewModel()) {
)
}
if (showConfirmDialog) {
if (emptyRecycleBin) {
AlertDialog(
onDismissRequest = { showConfirmDialog = false },
onDismissRequest = { viewModel.onCancelEmptyRecycleBinRequest() },
title = { Text("Empty Recycle Bin") },
text = {
Text("Are you sure you want to permanently delete all tasks in the recycle bin? This cannot be undone.")
@@ -138,7 +148,6 @@ fun EmptyRecycleBinAction(viewModel: RecycleBinViewModel = hiltViewModel()) {
TextButton(
onClick = {
viewModel.emptyRecycleBin()
showConfirmDialog = false
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
@@ -148,7 +157,7 @@ fun EmptyRecycleBinAction(viewModel: RecycleBinViewModel = hiltViewModel()) {
}
},
dismissButton = {
TextButton(onClick = { showConfirmDialog = false }) {
TextButton(onClick = { viewModel.onCancelEmptyRecycleBinRequest() }) {
Text("Cancel")
}
}

View File

@@ -1,5 +1,6 @@
package com.wismna.geoffroy.donext.presentation.ui.events
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
@@ -7,10 +8,24 @@ import javax.inject.Singleton
@Singleton
class UiEventBus @Inject constructor() {
private val _events = MutableSharedFlow<UiEvent>(replay = 1)
// Non-replayable (e.g. navigation, snackbar)
private val _events = MutableSharedFlow<UiEvent>(replay = 0, extraBufferCapacity = 1)
val events = _events.asSharedFlow()
// Replayable (e.g. edit/create task)
private val _stickyEvents = MutableSharedFlow<UiEvent>(replay = 1, extraBufferCapacity = 1)
val stickyEvents = _stickyEvents.asSharedFlow()
suspend fun send(event: UiEvent) {
_events.emit(event)
when (event) {
is UiEvent.EditTask,
is UiEvent.CreateNewTask -> _stickyEvents.emit(event)
else -> _events.emit(event)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun clearSticky() {
_stickyEvents.resetReplayCache()
}
}

View File

@@ -71,6 +71,7 @@ class MainViewModel @Inject constructor(
showTaskSheet = false
viewModelScope.launch {
uiEventBus.send(UiEvent.CloseTask)
uiEventBus.clearSticky()
}
}

View File

@@ -30,11 +30,15 @@ class RecycleBinViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
companion object {
private const val TASK_TO_DELETE = "taskToDeleteId"
private const val EMPTY_RECYCLE_BIN = "emptyRecycleBin"
}
var deletedTasks by mutableStateOf<List<TaskWithListName>>(emptyList())
private set
var taskToDelete by mutableStateOf(savedStateHandle.get<Long>("taskToDelete"))
private set
val taskToDeleteFlow = savedStateHandle.getStateFlow<Long?>(TASK_TO_DELETE, null)
val emptyRecycleBinFlow = savedStateHandle.getStateFlow<Boolean>(EMPTY_RECYCLE_BIN, false)
init {
loadDeletedTasks()
@@ -70,29 +74,36 @@ class RecycleBinViewModel @Inject constructor(
)
}
}
fun onTaskDeleteRequest(taskId: Long) {
taskToDelete = taskId
savedStateHandle["taskToDelete"] = taskId
fun onEmptyRecycleBinRequest() {
savedStateHandle[EMPTY_RECYCLE_BIN] = true
}
fun onConfirmDelete() {
taskToDelete?.let {
viewModelScope.launch {
permanentlyDeleteTaskUseCase(it)
}
}
taskToDelete = null
savedStateHandle["taskToDelete"] = null
}
fun onCancelDelete() {
taskToDelete = null
savedStateHandle["taskToDelete"] = null
fun onCancelEmptyRecycleBinRequest() {
savedStateHandle[EMPTY_RECYCLE_BIN] = false
}
fun emptyRecycleBin() {
viewModelScope.launch {
emptyRecycleBinUseCase()
savedStateHandle[EMPTY_RECYCLE_BIN] = false
}
}
fun onTaskDeleteRequest(taskId: Long) {
savedStateHandle[TASK_TO_DELETE] = taskId
}
fun onConfirmDelete() {
taskToDeleteFlow.value?.let {
viewModelScope.launch {
permanentlyDeleteTaskUseCase(it)
}
}
savedStateHandle[TASK_TO_DELETE] = null
}
fun onCancelDelete() {
savedStateHandle[TASK_TO_DELETE] = null
}
}

View File

@@ -37,13 +37,14 @@ class TaskViewModel @Inject constructor(
private set
var isDeleted by mutableStateOf(false)
private set
private var editingTaskId: Long? = null
private var taskListId: Long? = null
var taskListId by mutableStateOf<Long?>(null)
private set
var editingTaskId by mutableStateOf<Long?>(null)
private set
init {
viewModelScope.launch {
uiEventBus.events.collect { event ->
uiEventBus.stickyEvents.collect { event ->
when (event) {
is UiEvent.CreateNewTask -> startNewTask(event.taskListId)
is UiEvent.EditTask -> startEditTask(event.task)