Create UI events

Create a event bus singleton
Handle navigation through events
Handle task creation and edition through events
This commit is contained in:
Geoffroy Bonneville
2025-10-09 22:00:27 -04:00
parent 313e514624
commit e07f389fac
8 changed files with 106 additions and 50 deletions

View File

@@ -50,10 +50,11 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.wismna.geoffroy.donext.domain.model.AppDestination 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.MainViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -63,8 +64,6 @@ fun MainScreen(
val navController = rememberNavController() val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// TODO: find a way to do this better
val taskViewModel: TaskViewModel = hiltViewModel()
if (viewModel.isLoading) { if (viewModel.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -74,7 +73,7 @@ fun MainScreen(
} }
if (viewModel.showTaskSheet) { if (viewModel.showTaskSheet) {
TaskBottomSheet(taskViewModel) { viewModel.showTaskSheet = false } TaskBottomSheet { viewModel.onDismissTaskSheet() }
} }
if (viewModel.showAddListSheet) { if (viewModel.showAddListSheet) {
AddListBottomSheet { viewModel.showAddListSheet = false } AddListBottomSheet { viewModel.showAddListSheet = false }
@@ -85,21 +84,11 @@ fun MainScreen(
ModalNavigationDrawer( ModalNavigationDrawer(
drawerContent = { drawerContent = {
MenuScreen ( MenuScreen (currentDestination = viewModel.currentDestination)
currentDestination = viewModel.currentDestination,
onNavigate = { route ->
scope.launch {
drawerState.close()
navController.navigate(route) {
restoreState = true
}
}
}
)
}, },
drawerState = drawerState drawerState = drawerState
) { ) {
AppContent(viewModel = viewModel, taskViewModel = taskViewModel, navController = navController, scope = scope, drawerState = drawerState) AppContent(viewModel = viewModel, navController = navController, scope = scope, drawerState = drawerState)
} }
} }
@@ -107,11 +96,22 @@ fun MainScreen(
fun AppContent( fun AppContent(
modifier : Modifier = Modifier, modifier : Modifier = Modifier,
viewModel: MainViewModel, viewModel: MainViewModel,
taskViewModel: TaskViewModel,
navController: NavHostController, navController: NavHostController,
scope: CoroutineScope, scope: CoroutineScope,
drawerState: DrawerState drawerState: DrawerState
) { ) {
LaunchedEffect(Unit) {
viewModel.uiEventBus.events.collectLatest { event ->
when (event) {
is UiEvent.Navigate -> {
drawerState.close()
navController.navigate(event.route)
}
is UiEvent.NavigateBack -> navController.popBackStack()
else -> {}
}
}
}
Scaffold( Scaffold(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer), modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -126,7 +126,7 @@ fun AppContent(
navigationIcon = { navigationIcon = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
if (viewModel.currentDestination.showBackButton) { if (viewModel.currentDestination.showBackButton) {
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = { viewModel.navigateBack() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
} }
} else { } else {
@@ -158,10 +158,7 @@ fun AppContent(
when (val dest = viewModel.currentDestination) { when (val dest = viewModel.currentDestination) {
is AppDestination.TaskList -> { is AppDestination.TaskList -> {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = { onClick = { viewModel.onNewTaskButtonClicked(dest.taskListId) },
taskViewModel.startNewTask(dest.taskListId)
viewModel.showTaskSheet = true
},
icon = { Icon(Icons.Filled.Add, "Create a task.") }, icon = { Icon(Icons.Filled.Add, "Create a task.") },
text = { Text("Create a task") }, text = { Text("Create a task") },
) )
@@ -205,17 +202,14 @@ fun AppContent(
} }
LaunchedEffect(listExists) { LaunchedEffect(listExists) {
if (!viewModel.doesListExist(taskListId)) { if (!viewModel.doesListExist(taskListId)) {
navController.popBackStack() viewModel.navigateBack()
} }
} }
val taskListViewModel: TaskListViewModel = hiltViewModel(navBackStackEntry) val taskListViewModel: TaskListViewModel = hiltViewModel(navBackStackEntry)
TaskListScreen( TaskListScreen(
viewModel = taskListViewModel, viewModel = taskListViewModel,
onTaskClick = { task -> onTaskClick = { task -> viewModel.onTaskClicked(task) }
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
) )
} }
@@ -228,19 +222,13 @@ fun AppContent(
composable(AppDestination.DueTodayList.route) { composable(AppDestination.DueTodayList.route) {
DueTodayTasksScreen ( DueTodayTasksScreen (
modifier = Modifier, modifier = Modifier,
onTaskClick = { task -> onTaskClick = { task -> viewModel.onTaskClicked(task) }
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
) )
} }
composable(AppDestination.RecycleBin.route) { composable(AppDestination.RecycleBin.route) {
RecycleBinScreen( RecycleBinScreen(
modifier = Modifier, modifier = Modifier,
onTaskClick = { task -> onTaskClick = { task -> viewModel.onTaskClicked(task) }
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
) )
} }
} }

View File

@@ -31,7 +31,6 @@ import com.wismna.geoffroy.donext.presentation.viewmodel.MenuViewModel
fun MenuScreen( fun MenuScreen(
viewModel: MenuViewModel = hiltViewModel(), viewModel: MenuViewModel = hiltViewModel(),
currentDestination: AppDestination, currentDestination: AppDestination,
onNavigate: (String) -> Unit
) { ) {
ModalDrawerSheet( ModalDrawerSheet(
drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant, drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -58,7 +57,7 @@ fun MenuScreen(
}, },
icon = { Icon(Icons.Default.Today, contentDescription = "Due Today") }, icon = { Icon(Icons.Default.Today, contentDescription = "Due Today") },
selected = currentDestination is AppDestination.DueTodayList, selected = currentDestination is AppDestination.DueTodayList,
onClick = { onNavigate(AppDestination.DueTodayList.route) }, onClick = { viewModel.navigateTo(AppDestination.DueTodayList.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
HorizontalDivider() HorizontalDivider()
@@ -74,7 +73,7 @@ fun MenuScreen(
icon = { Icon(Icons.Default.LineWeight, contentDescription = list.name) }, icon = { Icon(Icons.Default.LineWeight, contentDescription = list.name) },
selected = currentDestination is AppDestination.TaskList && selected = currentDestination is AppDestination.TaskList &&
currentDestination.taskListId == list.id, currentDestination.taskListId == list.id,
onClick = { onNavigate("taskList/${list.id}") }, onClick = { viewModel.navigateTo("taskList/${list.id}") },
badge = { badge = {
if (list.overdueCount > 0) { if (list.overdueCount > 0) {
Badge { Text(list.overdueCount.toString()) } Badge { Text(list.overdueCount.toString()) }
@@ -91,14 +90,14 @@ fun MenuScreen(
label = { Text("Recycle Bin") }, label = { Text("Recycle Bin") },
icon = { Icon(Icons.Default.Delete, contentDescription = "Recycle Bin") }, icon = { Icon(Icons.Default.Delete, contentDescription = "Recycle Bin") },
selected = currentDestination is AppDestination.RecycleBin, selected = currentDestination is AppDestination.RecycleBin,
onClick = { onNavigate(AppDestination.RecycleBin.route) }, onClick = { viewModel.navigateTo(AppDestination.RecycleBin.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Edit Lists") }, label = { Text("Edit Lists") },
icon = { Icon(Icons.Default.EditNote, contentDescription = "Edit Lists") }, icon = { Icon(Icons.Default.EditNote, contentDescription = "Edit Lists") },
selected = currentDestination is AppDestination.ManageLists, selected = currentDestination is AppDestination.ManageLists,
onClick = { onNavigate(AppDestination.ManageLists.route) }, onClick = { viewModel.navigateTo(AppDestination.ManageLists.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
} }

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.extension.toLocalDate import com.wismna.geoffroy.donext.domain.extension.toLocalDate
import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
@@ -53,7 +54,7 @@ import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TaskBottomSheet( fun TaskBottomSheet(
viewModel: TaskViewModel, viewModel: TaskViewModel = hiltViewModel(),
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val titleFocusRequester = remember { FocusRequester() } val titleFocusRequester = remember { FocusRequester() }

View File

@@ -0,0 +1,13 @@
package com.wismna.geoffroy.donext.presentation.ui.events
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()
}

View File

@@ -7,15 +7,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import com.wismna.geoffroy.donext.domain.model.AppDestination import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
getTaskListsUseCase: GetTaskListsUseCase getTaskListsUseCase: GetTaskListsUseCase,
val uiEventBus: UiEventBus
) : ViewModel() { ) : ViewModel() {
var isLoading by mutableStateOf(true) var isLoading by mutableStateOf(true)
@@ -50,6 +55,33 @@ class MainViewModel @Inject constructor(
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
fun navigateBack() {
viewModelScope.launch {
uiEventBus.send(UiEvent.NavigateBack)
}
}
fun onNewTaskButtonClicked(taskLisId: Long) {
showTaskSheet = true
viewModelScope.launch {
uiEventBus.send(UiEvent.CreateNewTask(taskLisId))
}
}
fun onTaskClicked(task: Task) {
showTaskSheet = true
viewModelScope.launch {
uiEventBus.send(UiEvent.EditTask(task))
}
}
fun onDismissTaskSheet() {
showTaskSheet = false
viewModelScope.launch {
uiEventBus.send(UiEvent.CloseTask)
}
}
fun setCurrentDestination(navBackStackEntry: NavBackStackEntry?) { fun setCurrentDestination(navBackStackEntry: NavBackStackEntry?) {
val route = navBackStackEntry?.destination?.route val route = navBackStackEntry?.destination?.route
val taskListId = navBackStackEntry?.arguments?.getLong("taskListId") val taskListId = navBackStackEntry?.arguments?.getLong("taskListId")

View File

@@ -9,17 +9,20 @@ import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MenuViewModel @Inject constructor( class MenuViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase, getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase,
getDueTodayTasks: GetDueTodayTasksUseCase getDueTodayTasks: GetDueTodayTasksUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() { ) : ViewModel() {
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList()) var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set private set
@@ -38,4 +41,10 @@ class MenuViewModel @Inject constructor(
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
fun navigateTo(route: String) {
viewModelScope.launch {
uiEventBus.send(UiEvent.Navigate(route))
}
}
} }

View File

@@ -19,9 +19,9 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TaskListViewModel @Inject constructor( class TaskListViewModel @Inject constructor(
getTasks: GetTasksForListUseCase, getTasks: GetTasksForListUseCase,
savedStateHandle: SavedStateHandle,
private val toggleTaskDone: ToggleTaskDoneUseCase, private val toggleTaskDone: ToggleTaskDoneUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase, private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
var tasks by mutableStateOf<List<Task>>(emptyList()) var tasks by mutableStateOf<List<Task>>(emptyList())

View File

@@ -9,6 +9,8 @@ import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskUseCase import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskUseCase
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
@@ -19,7 +21,8 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TaskViewModel @Inject constructor( class TaskViewModel @Inject constructor(
private val createTaskUseCase: AddTaskUseCase, private val createTaskUseCase: AddTaskUseCase,
private val updateTaskUseCase: UpdateTaskUseCase private val updateTaskUseCase: UpdateTaskUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() { ) : ViewModel() {
var title by mutableStateOf("") var title by mutableStateOf("")
@@ -38,10 +41,23 @@ class TaskViewModel @Inject constructor(
private var editingTaskId: Long? = null private var editingTaskId: Long? = null
private var taskListId: Long? = null private var taskListId: Long? = null
init {
viewModelScope.launch {
uiEventBus.events.collect { event ->
when (event) {
is UiEvent.EditTask -> startEditTask(event.task)
is UiEvent.CreateNewTask -> startNewTask(event.taskListId)
is UiEvent.CloseTask -> reset()
else -> {}
}
}
}
}
fun screenTitle(): String = if (isDeleted) "Task details" else if (isEditing()) "Edit Task" else "New Task" fun screenTitle(): String = if (isDeleted) "Task details" else if (isEditing()) "Edit Task" else "New Task"
fun isEditing(): Boolean = editingTaskId != null fun isEditing(): Boolean = editingTaskId != null
fun startNewTask(selectedListId: Long) { private fun startNewTask(selectedListId: Long) {
editingTaskId = null editingTaskId = null
taskListId = selectedListId taskListId = selectedListId
title = "" title = ""
@@ -51,7 +67,7 @@ class TaskViewModel @Inject constructor(
isDeleted = false isDeleted = false
} }
fun startEditTask(task: Task) { private fun startEditTask(task: Task) {
editingTaskId = task.id editingTaskId = task.id
taskListId = task.taskListId taskListId = task.taskListId
title = task.name title = task.name
@@ -84,8 +100,6 @@ class TaskViewModel @Inject constructor(
} else { } else {
createTaskUseCase(taskListId!!, title, description, priority, dueDate) createTaskUseCase(taskListId!!, title, description, priority, dueDate)
} }
// reset state after save
reset()
onDone?.invoke() onDone?.invoke()
} }
} }