From 60cd307a8791d4f5947f7314952c77ff14fb4ded Mon Sep 17 00:00:00 2001 From: Geoffroy Bonneville <24917789+wismna@users.noreply.github.com> Date: Tue, 28 Oct 2025 22:15:36 -0400 Subject: [PATCH] Finally fix the double navigation issue event Dialogs now survive screen rotation Improve empty screens placeholders --- .../screen/DueTodayTasksScreen.kt | 15 +++++- .../donext/presentation/screen/MainScreen.kt | 10 +++- .../presentation/screen/RecycleBinScreen.kt | 31 +++++++----- .../presentation/ui/events/UiEventBus.kt | 19 +++++++- .../presentation/viewmodel/MainViewModel.kt | 1 + .../viewmodel/RecycleBinViewModel.kt | 47 ++++++++++++------- .../presentation/viewmodel/TaskViewModel.kt | 9 ++-- 7 files changed, 95 insertions(+), 37 deletions(-) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/DueTodayTasksScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/DueTodayTasksScreen.kt index 32df149..7de745f 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/DueTodayTasksScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/DueTodayTasksScreen.kt @@ -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( diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt index e735dc2..a9160a6 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt @@ -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, diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/RecycleBinScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/RecycleBinScreen.kt index c839dd6..a4cdd98 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/RecycleBinScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/RecycleBinScreen.kt @@ -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") } } diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/ui/events/UiEventBus.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/ui/events/UiEventBus.kt index f2a62c1..63951da 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/ui/events/UiEventBus.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/ui/events/UiEventBus.kt @@ -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(replay = 1) + // Non-replayable (e.g. navigation, snackbar) + private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) val events = _events.asSharedFlow() + // Replayable (e.g. edit/create task) + private val _stickyEvents = MutableSharedFlow(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() } } diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt index d2a5f6a..8490f2a 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt @@ -71,6 +71,7 @@ class MainViewModel @Inject constructor( showTaskSheet = false viewModelScope.launch { uiEventBus.send(UiEvent.CloseTask) + uiEventBus.clearSticky() } } diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/RecycleBinViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/RecycleBinViewModel.kt index c76b409..ebc5c96 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/RecycleBinViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/RecycleBinViewModel.kt @@ -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>(emptyList()) private set - var taskToDelete by mutableStateOf(savedStateHandle.get("taskToDelete")) - private set + val taskToDeleteFlow = savedStateHandle.getStateFlow(TASK_TO_DELETE, null) + val emptyRecycleBinFlow = savedStateHandle.getStateFlow(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 + } } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt index bd67fe9..cecd25b 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt @@ -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(null) + private set + var editingTaskId by mutableStateOf(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)