From 40a15f94cc1435e8cf6062cc38246f807c850025 Mon Sep 17 00:00:00 2001 From: Brayan Bedritchuk Date: Mon, 8 Dec 2025 21:36:35 -0300 Subject: [PATCH] Add Compose bottom navigation bar --- app/build.gradle.kts | 7 + .../com/sailboat/todozy/home/HomeActivity.kt | 208 +++++++++++++----- app/src/main/res/layout/activity_home.xml | 37 +++- app/src/main/res/navigation/nav_history.xml | 10 + app/src/main/res/navigation/nav_home.xml | 23 -- app/src/main/res/navigation/nav_settings.xml | 10 + app/src/main/res/navigation/nav_tasks.xml | 10 + feature/task-history/impl/build.gradle.kts | 1 + .../impl/presentation/TaskHistoryFragment.kt | 13 ++ .../impl/presentation/TaskListFragment.kt | 20 +- .../list/impl/presentation/TaskListScreen.kt | 59 +++-- .../viewmodel/TaskListViewIntent.kt | 1 + .../viewmodel/TaskListViewModel.kt | 70 ++++-- .../viewmodel/TaskListViewModelTest.kt | 57 +++++ .../uicomponent/impl/task/TaskItem.kt | 8 +- .../src/main/res/values-pt-rBR/strings.xml | 2 +- .../impl/src/main/res/values/strings.xml | 2 +- 17 files changed, 412 insertions(+), 126 deletions(-) create mode 100644 app/src/main/res/navigation/nav_history.xml delete mode 100644 app/src/main/res/navigation/nav_home.xml create mode 100644 app/src/main/res/navigation/nav_settings.xml create mode 100644 app/src/main/res/navigation/nav_tasks.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index efbf93e6..5da9c4c9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,6 +97,10 @@ android { buildFeatures { buildConfig = true viewBinding = true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = Compose.Version.compiler } } @@ -119,6 +123,9 @@ dependencies { implementation(Navigation.uiKtx) implementation(Koin.android) implementation(Timber.timber) + implementation(Compose.ui) + implementation(Compose.material) + implementation(Compose.materialIconsExtended) testImplementation(Junit.junit) testImplementation(MockK.core) diff --git a/app/src/main/java/br/com/sailboat/todozy/home/HomeActivity.kt b/app/src/main/java/br/com/sailboat/todozy/home/HomeActivity.kt index 3b74e452..b27837a6 100644 --- a/app/src/main/java/br/com/sailboat/todozy/home/HomeActivity.kt +++ b/app/src/main/java/br/com/sailboat/todozy/home/HomeActivity.kt @@ -4,24 +4,65 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material.BottomNavigation +import androidx.compose.material.BottomNavigationItem +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ListAlt +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource import androidx.core.view.isVisible -import androidx.navigation.NavController +import androidx.fragment.app.commit import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.navOptions -import br.com.sailboat.todozy.R import br.com.sailboat.todozy.databinding.ActivityHomeBinding import br.com.sailboat.todozy.feature.navigation.android.HomeDestination import br.com.sailboat.todozy.feature.navigation.android.HomeNavigationExtras.EXTRA_HOME_DESTINATION import br.com.sailboat.todozy.feature.navigation.android.HomeTabNavigator +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme +import br.com.sailboat.todozy.R as AppR +import br.com.sailboat.uicomponent.impl.R as UiR class HomeActivity : AppCompatActivity(), HomeTabNavigator { private lateinit var binding: ActivityHomeBinding + private var selectedTabId by mutableStateOf(AppR.id.nav_tasks) - private val rootDestinations = - setOf( - R.id.nav_tasks, - R.id.nav_history, - R.id.nav_settings, + private val navHostIds = + mapOf( + AppR.id.nav_tasks to AppR.id.tasks_nav_host, + AppR.id.nav_history to AppR.id.history_nav_host, + AppR.id.nav_settings to AppR.id.settings_nav_host, + ) + private val navGraphIds = + mapOf( + AppR.id.nav_tasks to AppR.navigation.nav_tasks, + AppR.id.nav_history to AppR.navigation.nav_history, + AppR.id.nav_settings to AppR.navigation.nav_settings, + ) + private val bottomBarItems = + listOf( + BottomBarItem( + id = AppR.id.nav_tasks, + icon = Icons.AutoMirrored.Filled.ListAlt, + title = UiR.string.label_tasks, + ), + BottomBarItem( + id = AppR.id.nav_history, + icon = Icons.Filled.History, + title = UiR.string.history, + ), + BottomBarItem( + id = AppR.id.nav_settings, + icon = Icons.Filled.Settings, + title = UiR.string.settings, + ), ) override fun onCreate(savedInstanceState: Bundle?) { @@ -29,74 +70,119 @@ class HomeActivity : AppCompatActivity(), HomeTabNavigator { binding = ActivityHomeBinding.inflate(layoutInflater) setContentView(binding.root) - val navController = resolveNavController() - configureBottomNav(navController) - applyStartDestination(navController) + ensureNavHostsCreated() + configureBottomBar() + applyStartDestination() } - private fun configureBottomNav(navController: NavController) { - binding.homeBottomNav.setOnItemSelectedListener { item -> - navigateToRoot(item.itemId, navController) - true - } - - binding.homeBottomNav.setOnItemReselectedListener { item -> - if (navController.currentDestination?.id == item.itemId) { - navController.popBackStack(item.itemId, inclusive = false) + private fun configureBottomBar() { + binding.homeBottomNav.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + binding.homeBottomNav.setContent { + TodozyTheme { + BottomNavigation( + backgroundColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + ) { + bottomBarItems.forEach { item -> + val selected = selectedTabId == item.id + BottomNavigationItem( + selected = selected, + onClick = { onBottomTabSelected(item.id) }, + icon = { + Icon(imageVector = item.icon, contentDescription = stringResource(id = item.title)) + }, + label = { Text(text = stringResource(id = item.title)) }, + selectedContentColor = MaterialTheme.colors.primary, + unselectedContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), + alwaysShowLabel = true, + ) + } + } } } - - navController.addOnDestinationChangedListener { _, destination, _ -> - binding.homeBottomNav.isVisible = destination.id in rootDestinations - } } - private fun applyStartDestination(navController: NavController) { + private fun applyStartDestination() { val targetDestination = when (intent.getSerializableExtra(EXTRA_HOME_DESTINATION) as? HomeDestination) { - HomeDestination.HISTORY -> R.id.nav_history - HomeDestination.SETTINGS -> R.id.nav_settings - else -> R.id.nav_tasks + HomeDestination.HISTORY -> AppR.id.nav_history + HomeDestination.SETTINGS -> AppR.id.nav_settings + else -> AppR.id.nav_tasks } - if (binding.homeBottomNav.selectedItemId != targetDestination) { - binding.homeBottomNav.selectedItemId = targetDestination - navigateToRoot(targetDestination, navController) - } + selectTab(targetDestination, allowReselectPop = false) } - private fun navigateToRoot( + private fun onBottomTabSelected(itemId: Int) { + selectTab(itemId, allowReselectPop = true) + } + + private fun selectTab( itemId: Int, - navController: NavController, + allowReselectPop: Boolean, ) { - val options = - navOptions { - launchSingleTop = true - restoreState = true - popUpTo(navController.graph.startDestinationId) { - saveState = true - } + if (itemId == AppR.id.nav_history) { + notifyHistoryTabSelected() + } + + if (allowReselectPop && itemId == selectedTabId) { + popToRoot(itemId) + return + } + + selectedTabId = itemId + showNavHost(itemId) + } + + private fun showNavHost(itemId: Int) { + val transaction = supportFragmentManager.beginTransaction() + + navHostIds.forEach { (destinationId, containerId) -> + val fragment = supportFragmentManager.findFragmentById(containerId) ?: return@forEach + val containerView = binding.root.findViewById(containerId) + if (destinationId == itemId) { + containerView?.isVisible = true + transaction.show(fragment) + transaction.setPrimaryNavigationFragment(fragment) + } else { + containerView?.isVisible = false + transaction.hide(fragment) } - navController.navigate(itemId, null, options) + } + + transaction.commit() } - private fun resolveNavController(): NavController { - val navHost = - supportFragmentManager.findFragmentById(R.id.home_nav_host) as NavHostFragment - return navHost.navController + private fun popToRoot(itemId: Int) { + val controller = navHostFragmentFor(itemId).navController + controller.popBackStack(controller.graph.startDestinationId, inclusive = false) + } + + private fun ensureNavHostsCreated() { + navHostIds.keys.forEach { navHostFragmentFor(it) } + } + + fun navHostFragmentFor(itemId: Int): NavHostFragment { + val containerId = navHostIds.getValue(itemId) + val existing = supportFragmentManager.findFragmentById(containerId) as? NavHostFragment + if (existing != null) return existing + + val navHostFragment = NavHostFragment.create(navGraphIds.getValue(itemId)) + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(containerId, navHostFragment) + } + supportFragmentManager.executePendingTransactions() + return navHostFragment } override fun switchTo(destination: HomeDestination) { - val navController = resolveNavController() val targetId = when (destination) { - HomeDestination.TASKS -> R.id.nav_tasks - HomeDestination.HISTORY -> R.id.nav_history - HomeDestination.SETTINGS -> R.id.nav_settings + HomeDestination.TASKS -> AppR.id.nav_tasks + HomeDestination.HISTORY -> AppR.id.nav_history + HomeDestination.SETTINGS -> AppR.id.nav_settings } - if (binding.homeBottomNav.selectedItemId != targetId) { - binding.homeBottomNav.selectedItemId = targetId - } - navigateToRoot(targetId, navController) + selectTab(targetId, allowReselectPop = false) } companion object { @@ -108,3 +194,17 @@ class HomeActivity : AppCompatActivity(), HomeTabNavigator { } } } + +private const val HISTORY_REFRESH_KEY = "history-refresh-request" + +private data class BottomBarItem( + val id: Int, + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val title: Int, +) + +private fun HomeActivity.notifyHistoryTabSelected() { + val navController = this.navHostFragmentFor(AppR.id.nav_history).navController + val historyBackStackEntry = navController.getBackStackEntry(AppR.id.nav_history) + historyBackStackEntry.savedStateHandle[HISTORY_REFRESH_KEY] = System.currentTimeMillis() +} diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index d86b697f..97e9a7dc 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -5,28 +5,49 @@ android:layout_height="match_parent"> + app:navGraph="@navigation/nav_tasks" /> - + + + + + app:layout_constraintStart_toStartOf="parent" /> diff --git a/app/src/main/res/navigation/nav_history.xml b/app/src/main/res/navigation/nav_history.xml new file mode 100644 index 00000000..e2fd7755 --- /dev/null +++ b/app/src/main/res/navigation/nav_history.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/navigation/nav_home.xml b/app/src/main/res/navigation/nav_home.xml deleted file mode 100644 index a415d3ef..00000000 --- a/app/src/main/res/navigation/nav_home.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/navigation/nav_settings.xml b/app/src/main/res/navigation/nav_settings.xml new file mode 100644 index 00000000..e5ec6c9f --- /dev/null +++ b/app/src/main/res/navigation/nav_settings.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/navigation/nav_tasks.xml b/app/src/main/res/navigation/nav_tasks.xml new file mode 100644 index 00000000..7ba899d5 --- /dev/null +++ b/app/src/main/res/navigation/nav_tasks.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/feature/task-history/impl/build.gradle.kts b/feature/task-history/impl/build.gradle.kts index 8ba84670..d6cec41c 100644 --- a/feature/task-history/impl/build.gradle.kts +++ b/feature/task-history/impl/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(project(Module.navigationPublicAndroid)) implementation(project(Module.taskHistoryPublic)) implementation(project(Module.taskDetailsPublic)) + implementation(Navigation.fragmentKtx) implementation(Coroutines.core) implementation(Coroutines.android) diff --git a/feature/task-history/impl/src/main/java/br/com/sailboat/todozy/feature/task/history/impl/presentation/TaskHistoryFragment.kt b/feature/task-history/impl/src/main/java/br/com/sailboat/todozy/feature/task/history/impl/presentation/TaskHistoryFragment.kt index 97c3e72b..0bd95944 100644 --- a/feature/task-history/impl/src/main/java/br/com/sailboat/todozy/feature/task/history/impl/presentation/TaskHistoryFragment.kt +++ b/feature/task-history/impl/src/main/java/br/com/sailboat/todozy/feature/task/history/impl/presentation/TaskHistoryFragment.kt @@ -11,6 +11,7 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import br.com.sailboat.todozy.feature.task.history.impl.databinding.FrgTaskHistoryBinding import br.com.sailboat.todozy.feature.task.history.impl.presentation.dialog.TaskHistoryFilterDialog @@ -151,6 +152,7 @@ internal class TaskHistoryFragment : Fragment(), SearchMenu by SearchMenuImpl() initViews() observeViewModel() viewModel.dispatchViewIntent(OnStart) + observeRefreshRequests() } override fun onResume() { @@ -251,6 +253,15 @@ internal class TaskHistoryFragment : Fragment(), SearchMenu by SearchMenuImpl() deleteTaskHistoryDialog?.callback = deleteTaskHistoryCallback } + private fun observeRefreshRequests() { + val navController = findNavController() + navController.currentBackStackEntry?.savedStateHandle + ?.getLiveData(HISTORY_REFRESH_KEY) + ?.observe(viewLifecycleOwner) { + viewModel.dispatchViewIntent(OnStart) + } + } + private fun navigateToMenuFilter(viewAction: TaskHistoryViewAction.NavigateToMenuFilter) { taskHistoryFilterDialog = TaskHistoryFilterDialog.show( @@ -411,3 +422,5 @@ internal class TaskHistoryFragment : Fragment(), SearchMenu by SearchMenuImpl() ) } } + +private const val HISTORY_REFRESH_KEY = "history-refresh-request" diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListFragment.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListFragment.kt index 1ffda087..56683ad2 100644 --- a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListFragment.kt +++ b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListFragment.kt @@ -1,5 +1,6 @@ package br.com.sailboat.todozy.feature.task.list.impl.presentation +import android.app.Activity import android.os.Bundle import android.view.View import android.view.ViewGroup @@ -35,10 +36,13 @@ internal class TaskListFragment : Fragment() { private val taskFormNavigator: TaskFormNavigator by inject() private val aboutNavigator: AboutNavigator by inject() private val settingsNavigator: SettingsNavigator by inject() + private var forceReloadOnResume = false private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - viewModel.dispatchViewIntent(TaskListViewIntent.OnStart) + if (it.resultCode == Activity.RESULT_OK) { + forceReloadOnResume = true + } } override fun onCreateView( @@ -98,8 +102,6 @@ internal class TaskListFragment : Fragment() { haptics.performHapticFeedback(HapticFeedbackType.LongPress) viewModel.dispatchViewIntent(TaskListViewIntent.OnClickNewTask) }, - onOpenHistory = { viewModel.dispatchViewIntent(TaskListViewIntent.OnClickMenuHistory) }, - onOpenSettings = { viewModel.dispatchViewIntent(TaskListViewIntent.OnClickMenuSettings) }, onSearch = { term -> viewModel.dispatchViewIntent( TaskListViewIntent.OnSubmitSearchTerm(term = term), @@ -118,6 +120,14 @@ internal class TaskListFragment : Fragment() { viewModel.dispatchViewIntent(TaskListViewIntent.OnStart) } + override fun onResume() { + super.onResume() + viewModel.dispatchViewIntent( + TaskListViewIntent.OnResume(forceReload = forceReloadOnResume), + ) + forceReloadOnResume = false + } + private fun observeActions() { viewModel.viewState.viewAction.observe(viewLifecycleOwner) { viewAction -> when (viewAction) { @@ -174,8 +184,4 @@ internal class TaskListFragment : Fragment() { private fun navigateToAbout() { activity?.run { aboutNavigator.navigateToAbout(this) } } - - companion object { - fun newInstance() = TaskListFragment() - } } diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListScreen.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListScreen.kt index 3271418c..0647a301 100644 --- a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListScreen.kt +++ b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.DismissDirection import androidx.compose.material.DismissValue import androidx.compose.material.ExperimentalMaterialApi @@ -39,15 +40,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import br.com.sailboat.todozy.domain.model.TaskMetrics import br.com.sailboat.todozy.domain.model.TaskProgressDay @@ -82,8 +89,6 @@ internal fun TaskListScreen( onTaskSwipe: (taskId: Long, TaskStatus) -> Unit, onTaskUndo: (Long, TaskStatus) -> Unit, onNewTask: () -> Unit, - onOpenHistory: () -> Unit, - onOpenSettings: () -> Unit, onSearch: (String) -> Unit, ) { TodozyTheme { @@ -114,8 +119,6 @@ internal fun TaskListScreen( onSearch(query) }, onToggleSearch = { isSearchExpanded = it }, - onOpenHistory = onOpenHistory, - onOpenSettings = onOpenSettings, ) }, floatingActionButton = { @@ -176,7 +179,12 @@ internal fun TaskListScreen( task = item, onClick = onTaskClick, onUndoClick = onTaskUndo, - onSwipe = { status -> onTaskSwipe(item.taskId, status) }, + onSwipe = { status -> + onTaskSwipe( + item.taskId, + status, + ) + }, modifier = Modifier.animateItem(), ) @@ -207,10 +215,21 @@ private fun TaskListTopBar( isSearchExpanded: Boolean, onSearchChange: (String) -> Unit, onToggleSearch: (Boolean) -> Unit, - onOpenHistory: () -> Unit, - onOpenSettings: () -> Unit, ) { val spacing = LocalTodozySpacing.current + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + LaunchedEffect(isSearchExpanded) { + if (isSearchExpanded) { + focusRequester.requestFocus() + keyboardController?.show() + } else { + focusManager.clearFocus() + keyboardController?.hide() + } + } Surface( color = colorResource(id = UiR.color.md_blue_500), elevation = 4.dp, @@ -246,7 +265,10 @@ private fun TaskListTopBar( TextField( value = searchText, onValueChange = onSearchChange, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), singleLine = true, placeholder = { Text(text = stringResource(id = AndroidUtilR.string.search)) }, leadingIcon = { @@ -272,6 +294,10 @@ private fun TaskListTopBar( ) } }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), colors = TextFieldDefaults.textFieldColors( backgroundColor = Color.White, @@ -313,10 +339,12 @@ private fun SwipeableTaskItem( onSwipe(TaskStatus.DONE) dismissState.reset() } + DismissValue.DismissedToStart -> { onSwipe(TaskStatus.NOT_DONE) dismissState.reset() } + else -> Unit } } @@ -335,16 +363,15 @@ private fun SwipeableTaskItem( dismissThresholds = { FractionalThreshold(0.35f) }, background = { val direction = dismissState.dismissDirection - val (icon, backgroundColor) = - when (direction) { - DismissDirection.StartToEnd -> - UiR.drawable.ic_vec_thumb_up_white_24dp to colorResource(id = UiR.color.md_teal_200) + val (icon, backgroundColor) = when (direction) { + DismissDirection.StartToEnd -> + UiR.drawable.ic_vec_thumb_up_white_24dp to colorResource(id = UiR.color.md_teal_200) - DismissDirection.EndToStart -> - UiR.drawable.ic_vect_thumb_down_white_24dp to colorResource(id = UiR.color.md_red_200) + DismissDirection.EndToStart -> + UiR.drawable.ic_vect_thumb_down_white_24dp to colorResource(id = UiR.color.md_red_200) - else -> null to Color.Transparent - } + else -> null to Color.Transparent + } if (icon != null) { Row( diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewIntent.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewIntent.kt index e231bdb9..6c946f28 100644 --- a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewIntent.kt +++ b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewIntent.kt @@ -5,6 +5,7 @@ import br.com.sailboat.todozy.domain.model.TaskStatus internal sealed class TaskListViewIntent { object OnStart : TaskListViewIntent() + data class OnResume(val forceReload: Boolean = false) : TaskListViewIntent() object OnClickMenuAbout : TaskListViewIntent() object OnClickMenuSettings : TaskListViewIntent() object OnClickMenuHistory : TaskListViewIntent() diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModel.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModel.kt index abd322bc..266faf29 100644 --- a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModel.kt +++ b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModel.kt @@ -57,19 +57,30 @@ internal class TaskListViewModel( private val logService: LogService, private val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider, ) : BaseViewModel() { + private val taskCategories = + listOf( + TaskCategory.BEFORE_TODAY, + TaskCategory.TODAY, + TaskCategory.TOMORROW, + TaskCategory.NEXT_DAYS, + ) private var taskFilter = TaskFilter(category = TaskCategory.TODAY) private var selectedProgressRange = TaskProgressRange.LAST_YEAR private var currentTasks: List = emptyList() + private var currentTasksByCategory: Map> = emptyMap() private var baseTaskMetrics: TaskMetrics? = null private val baseTaskConsecutive: MutableMap = mutableMapOf() private val inlineTaskFeedbacks: MutableMap = mutableMapOf() private val inlineTaskJobs: MutableMap = mutableMapOf() private var progressJob: Job? = null private val progressCache: MutableMap> = mutableMapOf() + private var hasLoaded = false + private var lastFullLoadDate: LocalDate? = null override fun dispatchViewIntent(viewIntent: TaskListViewIntent) { when (viewIntent) { is OnStart -> onStart() + is TaskListViewIntent.OnResume -> onResume(viewIntent.forceReload) is OnClickMenuAbout -> onClickMenuAbout() is OnClickMenuSettings -> onClickMenuSettings() is OnClickMenuHistory -> onClickMenuHistory() @@ -83,15 +94,38 @@ internal class TaskListViewModel( } private fun onStart() = viewModelScope.launch { + performFullLoad(closeNotifications = true) + } + + private fun onResume(forceReload: Boolean) = viewModelScope.launch { + if (hasLoaded.not()) { + return@launch + } + + val today = LocalDate.now() + val midnightPassed = lastFullLoadDate?.isBefore(today) == true + + when { + forceReload -> performFullLoad(closeNotifications = true) + midnightPassed -> performFullLoad(closeNotifications = true) + else -> refreshTasksFromCache() + } + } + + private suspend fun performFullLoad(closeNotifications: Boolean = false) { try { viewState.tasksLoading.postValue(true) viewState.taskProgressLoading.postValue(true) - viewState.viewAction.postValue(TaskListViewAction.CloseNotifications) + if (closeNotifications) { + viewState.viewAction.postValue(TaskListViewAction.CloseNotifications) + } loadTasks() viewState.tasksLoading.postValue(false) loadTaskMetrics() loadProgress() scheduleAllAlarmsUseCase() + lastFullLoadDate = LocalDate.now() + hasLoaded = true } catch (e: Exception) { logService.error(e) viewState.viewAction.value = TaskListViewAction.ShowErrorLoadingTasks @@ -198,16 +232,9 @@ internal class TaskListViewModel( } private suspend fun loadTasks() = coroutineScope { - val domainTasks = mutableListOf() - val taskCategories = - listOf( - TaskCategory.BEFORE_TODAY, - TaskCategory.TODAY, - TaskCategory.TOMORROW, - TaskCategory.NEXT_DAYS, - ) + val domainTasksByCategory = mutableMapOf>() - val tasks = + val tasksUiModels = taskCategories.map { category -> async { val filter = @@ -220,13 +247,30 @@ internal class TaskListViewModel( getTasksUseCase(filter).getOrThrow() } - domainTasks.addAll(tasks) + domainTasksByCategory[category] = tasks taskListUiModelFactory.create(tasks, category) } }.awaitAll().flatten() - val itemsWithFeedback = applyInlineFeedbacks(tasks) - currentTasks = domainTasks + val itemsWithFeedback = applyInlineFeedbacks(tasksUiModels) + currentTasksByCategory = domainTasksByCategory.toMap() + currentTasks = taskCategories.flatMap { category -> domainTasksByCategory[category].orEmpty() } + viewState.itemsView.postValue(itemsWithFeedback.toMutableList()) + } + + private fun refreshTasksFromCache() { + if (currentTasksByCategory.isEmpty()) { + return + } + + val uiModels = + taskCategories + .map { category -> + val tasks = currentTasksByCategory[category].orEmpty() + taskListUiModelFactory.create(tasks, category) + }.flatten() + + val itemsWithFeedback = applyInlineFeedbacks(uiModels) viewState.itemsView.postValue(itemsWithFeedback.toMutableList()) } diff --git a/feature/task-list/impl/src/test/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModelTest.kt b/feature/task-list/impl/src/test/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModelTest.kt index c89bf819..6d4c2484 100644 --- a/feature/task-list/impl/src/test/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModelTest.kt +++ b/feature/task-list/impl/src/test/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModelTest.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test +import java.time.LocalDate import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -118,6 +119,56 @@ internal class TaskListViewModelTest { coVerify { getTaskProgressUseCase(TaskProgressFilter(TaskProgressRange.LAST_YEAR)) } } + @Test + fun `should not reload tasks on resume before first load`() = runTest(coroutinesTestRule.dispatcher) { + prepareScenario() + + viewModel.dispatchViewIntent(TaskListViewIntent.OnResume()) + advanceUntilIdle() + + coVerify(exactly = 0) { getTasksUseCase(any()) } + } + + @Test + fun `should refresh from cache on resume without forcing reload`() = runTest(coroutinesTestRule.dispatcher) { + prepareScenario() + + viewModel.dispatchViewIntent(TaskListViewIntent.OnStart) + advanceUntilIdle() + + viewModel.dispatchViewIntent(TaskListViewIntent.OnResume()) + advanceUntilIdle() + + coVerify(exactly = 4) { getTasksUseCase(any()) } + } + + @Test + fun `should reload tasks when resume is forced`() = runTest(coroutinesTestRule.dispatcher) { + prepareScenario() + + viewModel.dispatchViewIntent(TaskListViewIntent.OnStart) + advanceUntilIdle() + + viewModel.dispatchViewIntent(TaskListViewIntent.OnResume(forceReload = true)) + advanceUntilIdle() + + coVerify(exactly = 8) { getTasksUseCase(any()) } + } + + @Test + fun `should reload tasks when day has changed`() = runTest(coroutinesTestRule.dispatcher) { + prepareScenario() + + viewModel.dispatchViewIntent(TaskListViewIntent.OnStart) + advanceUntilIdle() + setLastFullLoadDate(LocalDate.now().minusDays(1)) + + viewModel.dispatchViewIntent(TaskListViewIntent.OnResume()) + advanceUntilIdle() + + coVerify(exactly = 8) { getTasksUseCase(any()) } + } + @Test fun `should hide metrics and progress when no tasks are loaded`() = runTest(coroutinesTestRule.dispatcher) { prepareScenario(tasksView = emptyList(), tasksResult = Result.success(emptyList())) @@ -452,6 +503,12 @@ internal class TaskListViewModelTest { ) } + private fun setLastFullLoadDate(date: LocalDate) { + val field = TaskListViewModel::class.java.getDeclaredField("lastFullLoadDate") + field.isAccessible = true + field.set(viewModel, date) + } + private fun prepareScenario( tasksView: List = listOf( diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt index efb0d7a5..e30c461d 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt @@ -192,7 +192,7 @@ private fun InlineMetrics( Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacingSmall), + horizontalArrangement = Arrangement.spacedBy(spacingSmall * 1.5f), ) { MetricsContent( metrics = metrics, @@ -257,9 +257,11 @@ private fun MetricsContent( if (metricChips.isEmpty()) return Row( - modifier = modifier.defaultMinSize(minHeight = 40.dp), + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 40.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacingSmall), + horizontalArrangement = Arrangement.SpaceBetween, ) { metricChips.forEach { chip -> MetricChip( diff --git a/ui-component/impl/src/main/res/values-pt-rBR/strings.xml b/ui-component/impl/src/main/res/values-pt-rBR/strings.xml index 441b1872..c71a6ffe 100644 --- a/ui-component/impl/src/main/res/values-pt-rBR/strings.xml +++ b/ui-component/impl/src/main/res/values-pt-rBR/strings.xml @@ -57,7 +57,7 @@ Filtrar histórico Todozy é um aplicativo minimalista de gerenciamento de tarefas que te ajuda a ser mais produtivo e a construir novos hábitos. - Todozy + Tarefas Progresso A data de conclusão deve ser maior que a data inicial Tarefas feitas diff --git a/ui-component/impl/src/main/res/values/strings.xml b/ui-component/impl/src/main/res/values/strings.xml index 2d8bbbae..2ce9c2e9 100644 --- a/ui-component/impl/src/main/res/values/strings.xml +++ b/ui-component/impl/src/main/res/values/strings.xml @@ -61,7 +61,7 @@ Todozy is a minimalist task management app that helps you to be more productive and build new habits. No projects found - Todozy + Tasks Progress Deadline must be greater than the initial date Tasks done