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 package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn 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.filled.CalendarToday
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.ui.Alignment import androidx.compose.ui.Alignment
@@ -26,7 +32,14 @@ fun DueTodayTasksScreen(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center 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 { } else {
LazyColumn( LazyColumn(

View File

@@ -206,7 +206,6 @@ fun AppContent(
navController.navigate(event.route) navController.navigate(event.route)
} }
is UiEvent.NavigateBack -> navController.popBackStack() is UiEvent.NavigateBack -> navController.popBackStack()
is UiEvent.EditTask -> { viewModel.showTaskSheet = true }
is UiEvent.ShowUndoSnackbar -> { is UiEvent.ShowUndoSnackbar -> {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
message = event.message, 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( Scaffold(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer), modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent, containerColor = Color.Transparent,

View File

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

View File

@@ -1,5 +1,6 @@
package com.wismna.geoffroy.donext.presentation.ui.events package com.wismna.geoffroy.donext.presentation.ui.events
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject import javax.inject.Inject
@@ -7,10 +8,24 @@ import javax.inject.Singleton
@Singleton @Singleton
class UiEventBus @Inject constructor() { 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() 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) { 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 showTaskSheet = false
viewModelScope.launch { viewModelScope.launch {
uiEventBus.send(UiEvent.CloseTask) uiEventBus.send(UiEvent.CloseTask)
uiEventBus.clearSticky()
} }
} }

View File

@@ -30,11 +30,15 @@ class RecycleBinViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle private val savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
companion object {
private const val TASK_TO_DELETE = "taskToDeleteId"
private const val EMPTY_RECYCLE_BIN = "emptyRecycleBin"
}
var deletedTasks by mutableStateOf<List<TaskWithListName>>(emptyList()) var deletedTasks by mutableStateOf<List<TaskWithListName>>(emptyList())
private set private set
var taskToDelete by mutableStateOf(savedStateHandle.get<Long>("taskToDelete")) val taskToDeleteFlow = savedStateHandle.getStateFlow<Long?>(TASK_TO_DELETE, null)
private set val emptyRecycleBinFlow = savedStateHandle.getStateFlow<Boolean>(EMPTY_RECYCLE_BIN, false)
init { init {
loadDeletedTasks() loadDeletedTasks()
@@ -70,29 +74,36 @@ class RecycleBinViewModel @Inject constructor(
) )
} }
} }
fun onTaskDeleteRequest(taskId: Long) {
taskToDelete = taskId fun onEmptyRecycleBinRequest() {
savedStateHandle["taskToDelete"] = taskId savedStateHandle[EMPTY_RECYCLE_BIN] = true
} }
fun onConfirmDelete() { fun onCancelEmptyRecycleBinRequest() {
taskToDelete?.let { savedStateHandle[EMPTY_RECYCLE_BIN] = false
viewModelScope.launch {
permanentlyDeleteTaskUseCase(it)
}
}
taskToDelete = null
savedStateHandle["taskToDelete"] = null
}
fun onCancelDelete() {
taskToDelete = null
savedStateHandle["taskToDelete"] = null
} }
fun emptyRecycleBin() { fun emptyRecycleBin() {
viewModelScope.launch { viewModelScope.launch {
emptyRecycleBinUseCase() 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 private set
var isDeleted by mutableStateOf(false) var isDeleted by mutableStateOf(false)
private set private set
var taskListId by mutableStateOf<Long?>(null)
private var editingTaskId: Long? = null private set
private var taskListId: Long? = null var editingTaskId by mutableStateOf<Long?>(null)
private set
init { init {
viewModelScope.launch { viewModelScope.launch {
uiEventBus.events.collect { event -> uiEventBus.stickyEvents.collect { event ->
when (event) { when (event) {
is UiEvent.CreateNewTask -> startNewTask(event.taskListId) is UiEvent.CreateNewTask -> startNewTask(event.taskListId)
is UiEvent.EditTask -> startEditTask(event.task) is UiEvent.EditTask -> startEditTask(event.task)