Compare commits

...

3 Commits

Author SHA1 Message Date
Geoffroy Bonneville
e07f389fac Create UI events
Create a event bus singleton
Handle navigation through events
Handle task creation and edition through events
2025-10-09 22:00:27 -04:00
Geoffroy Bonneville
313e514624 Cleanup 2025-10-09 16:44:35 -04:00
Geoffroy Bonneville
8e78f9b464 Fix navigation issue when navigating back to a deleted list
Some refactoring
2025-10-09 16:43:27 -04:00
13 changed files with 183 additions and 81 deletions

View File

@@ -232,6 +232,18 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2640" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="blazer" />
<option name="id" value="blazer" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 10 Pro" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2410" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
@@ -439,6 +451,18 @@
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="frankel" />
<option name="id" value="frankel" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 10" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
@@ -657,6 +681,18 @@
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="mustang" />
<option name="id" value="mustang" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 10 Pro XL" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1080" />
<option name="screenY" value="2404" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
@@ -755,6 +791,18 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="rango" />
<option name="id" value="rango" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 10 Pro Fold" />
<option name="screenDensity" value="390" />
<option name="screenX" value="2076" />
<option name="screenY" value="2152" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />

View File

@@ -15,7 +15,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.DueTodayViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@Composable
fun DueTodayTasksScreen(
@@ -41,7 +40,7 @@ fun DueTodayTasksScreen(
items(tasks, key = { it.id!! }) { task ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
task = task,
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!)

View File

@@ -18,6 +18,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
@@ -31,7 +32,10 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -46,22 +50,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
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 com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@Composable
fun MainScreen(
modifier: Modifier = Modifier,
viewModel: MainViewModel = hiltViewModel()
) {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// TODO: find a way to get rid of this
val taskViewModel: TaskViewModel = hiltViewModel()
if (viewModel.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -71,7 +73,7 @@ fun MainScreen(
}
if (viewModel.showTaskSheet) {
TaskBottomSheet(taskViewModel) { viewModel.showTaskSheet = false }
TaskBottomSheet { viewModel.onDismissTaskSheet() }
}
if (viewModel.showAddListSheet) {
AddListBottomSheet { viewModel.showAddListSheet = false }
@@ -82,21 +84,11 @@ fun MainScreen(
ModalNavigationDrawer(
drawerContent = {
MenuScreen (
currentDestination = viewModel.currentDestination,
onNavigate = { route ->
scope.launch {
drawerState.close()
navController.navigate(route) {
restoreState = true
}
}
}
)
MenuScreen (currentDestination = viewModel.currentDestination)
},
drawerState = drawerState
) {
AppContent(viewModel = viewModel, taskViewModel = taskViewModel, navController = navController, scope = scope, drawerState = drawerState)
AppContent(viewModel = viewModel, navController = navController, scope = scope, drawerState = drawerState)
}
}
@@ -104,11 +96,22 @@ fun MainScreen(
fun AppContent(
modifier : Modifier = Modifier,
viewModel: MainViewModel,
taskViewModel: TaskViewModel,
navController: NavHostController,
scope: CoroutineScope,
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(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent,
@@ -123,7 +126,7 @@ fun AppContent(
navigationIcon = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
if (viewModel.currentDestination.showBackButton) {
IconButton(onClick = { navController.popBackStack() }) {
IconButton(onClick = { viewModel.navigateBack() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
} else {
@@ -154,9 +157,10 @@ fun AppContent(
floatingActionButton = {
when (val dest = viewModel.currentDestination) {
is AppDestination.TaskList -> {
TaskListFab(
taskListId = dest.taskListId,
showBottomSheet = { viewModel.showTaskSheet = it }
ExtendedFloatingActionButton(
onClick = { viewModel.onNewTaskButtonClicked(dest.taskListId) },
icon = { Icon(Icons.Filled.Add, "Create a task.") },
text = { Text("Create a task") },
)
}
else -> null
@@ -192,14 +196,20 @@ fun AppContent(
type = NavType.LongType
})
) { navBackStackEntry ->
// TODO: when task list has been deleted, we should not navigate to it event if in the stack
val taskListId = navBackStackEntry.arguments?.getLong("taskListId") ?: return@composable
val listExists by remember(taskListId, viewModel.destinations) {
derivedStateOf { viewModel.doesListExist(taskListId) }
}
LaunchedEffect(listExists) {
if (!viewModel.doesListExist(taskListId)) {
viewModel.navigateBack()
}
}
val taskListViewModel: TaskListViewModel = hiltViewModel(navBackStackEntry)
TaskListScreen(
viewModel = taskListViewModel,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
onTaskClick = { task -> viewModel.onTaskClicked(task) }
)
}
@@ -212,19 +222,13 @@ fun AppContent(
composable(AppDestination.DueTodayList.route) {
DueTodayTasksScreen (
modifier = Modifier,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
onTaskClick = { task -> viewModel.onTaskClicked(task) }
)
}
composable(AppDestination.RecycleBin.route) {
RecycleBinScreen(
modifier = Modifier,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
onTaskClick = { task -> viewModel.onTaskClicked(task) }
)
}
}

View File

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

View File

@@ -31,7 +31,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.RecycleBinViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@Composable
fun RecycleBinScreen(
@@ -77,7 +76,7 @@ fun RecycleBinScreen(
items(items, key = { it.task.id!! }) { item ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(item.task),
task = item.task,
onTaskClick = { onTaskClick(item.task) },
onSwipeLeft = {
viewModel.restore(item.task.id!!)

View File

@@ -38,16 +38,18 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@Composable
fun TaskItemScreen(
modifier: Modifier = Modifier,
viewModel: TaskItemViewModel,
task: Task,
onTaskClick: (taskId: Long) -> Unit,
onSwipeLeft: () -> Unit,
onSwipeRight: () -> Unit
) {
val viewModel = TaskItemViewModel(task)
// TODO: change this
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {

View File

@@ -8,12 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -21,13 +16,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
@Composable
fun TaskListScreen(
modifier: Modifier = Modifier,
viewModel: TaskListViewModel = hiltViewModel<TaskListViewModel>(),
onTaskClick: (Task) -> Unit) {
val tasks = viewModel.tasks
@@ -50,7 +42,7 @@ fun TaskListScreen(
) { task ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
task = task,
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!, true)
@@ -81,7 +73,7 @@ fun TaskListScreen(
) { task ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
task = task,
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!, false)
@@ -95,20 +87,4 @@ fun TaskListScreen(
}
}
}
@Composable
fun TaskListFab(
taskListId: Long,
viewModel: TaskViewModel = hiltViewModel(),
showBottomSheet: (Boolean) -> Unit = {}
) {
ExtendedFloatingActionButton(
onClick = {
viewModel.startNewTask(taskListId)
showBottomSheet(true)
},
icon = { Icon(Icons.Filled.Add, "Create a task.") },
text = { Text("Create a task") },
)
}

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.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.extension.toLocalDate
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
@@ -53,7 +54,7 @@ import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskBottomSheet(
viewModel: TaskViewModel,
viewModel: TaskViewModel = hiltViewModel(),
onDismiss: () -> Unit
) {
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.navigation.NavBackStackEntry
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.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
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
getTaskListsUseCase: GetTaskListsUseCase
getTaskListsUseCase: GetTaskListsUseCase,
val uiEventBus: UiEventBus
) : ViewModel() {
var isLoading by mutableStateOf(true)
@@ -50,6 +55,33 @@ class MainViewModel @Inject constructor(
.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?) {
val route = navBackStackEntry?.destination?.route
val taskListId = navBackStackEntry?.arguments?.getLong("taskListId")
@@ -61,4 +93,10 @@ class MainViewModel @Inject constructor(
}
} ?: startDestination
}
fun doesListExist(taskListId: Long): Boolean {
return destinations.any { dest ->
dest is AppDestination.TaskList && dest.taskListId == 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.usecase.GetDueTodayTasksUseCase
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 kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MenuViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase,
getDueTodayTasks: GetDueTodayTasksUseCase
getDueTodayTasks: GetDueTodayTasksUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() {
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set
@@ -38,4 +41,10 @@ class MenuViewModel @Inject constructor(
}
.launchIn(viewModelScope)
}
fun navigateTo(route: String) {
viewModelScope.launch {
uiEventBus.send(UiEvent.Navigate(route))
}
}
}

View File

@@ -19,9 +19,9 @@ import javax.inject.Inject
@HiltViewModel
class TaskListViewModel @Inject constructor(
getTasks: GetTasksForListUseCase,
savedStateHandle: SavedStateHandle,
private val toggleTaskDone: ToggleTaskDoneUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
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.usecase.AddTaskUseCase
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 kotlinx.coroutines.launch
import java.time.Instant
@@ -19,7 +21,8 @@ import javax.inject.Inject
@HiltViewModel
class TaskViewModel @Inject constructor(
private val createTaskUseCase: AddTaskUseCase,
private val updateTaskUseCase: UpdateTaskUseCase
private val updateTaskUseCase: UpdateTaskUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() {
var title by mutableStateOf("")
@@ -38,10 +41,23 @@ class TaskViewModel @Inject constructor(
private var editingTaskId: 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 isEditing(): Boolean = editingTaskId != null
fun startNewTask(selectedListId: Long) {
private fun startNewTask(selectedListId: Long) {
editingTaskId = null
taskListId = selectedListId
title = ""
@@ -51,7 +67,7 @@ class TaskViewModel @Inject constructor(
isDeleted = false
}
fun startEditTask(task: Task) {
private fun startEditTask(task: Task) {
editingTaskId = task.id
taskListId = task.taskListId
title = task.name
@@ -84,8 +100,6 @@ class TaskViewModel @Inject constructor(
} else {
createTaskUseCase(taskListId!!, title, description, priority, dueDate)
}
// reset state after save
reset()
onDone?.invoke()
}
}