diff --git a/feature/task-details/impl/build.gradle.kts b/feature/task-details/impl/build.gradle.kts index 702cac26..cf10c21c 100644 --- a/feature/task-details/impl/build.gradle.kts +++ b/feature/task-details/impl/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("todozy.android.library") + id("todozy.android.library.compose") } android { @@ -26,6 +26,10 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.material) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.compose.material.icons.extended) testImplementation(libs.junit4) testImplementation(libs.mockk.core) diff --git a/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/TaskDetailsFragment.kt b/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/TaskDetailsFragment.kt index 98a1ef02..c9bfbdb3 100644 --- a/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/TaskDetailsFragment.kt +++ b/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/TaskDetailsFragment.kt @@ -3,20 +3,17 @@ package br.com.sailboat.todozy.feature.task.details.impl.presentation import android.app.Activity import android.os.Bundle import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.TooltipCompat -import androidx.core.view.MenuProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager -import br.com.sailboat.todozy.domain.model.TaskMetrics import br.com.sailboat.todozy.domain.model.TaskProgressRange import br.com.sailboat.todozy.feature.navigation.android.TaskFormNavigator import br.com.sailboat.todozy.feature.task.details.impl.databinding.FrgTaskDetailsBinding @@ -24,7 +21,6 @@ import br.com.sailboat.todozy.feature.task.details.impl.presentation.viewmodel.T import br.com.sailboat.todozy.feature.task.details.impl.presentation.viewmodel.TaskDetailsViewIntent import br.com.sailboat.todozy.feature.task.details.impl.presentation.viewmodel.TaskDetailsViewIntent.OnClickConfirmDeleteTask import br.com.sailboat.todozy.feature.task.details.impl.presentation.viewmodel.TaskDetailsViewModel -import br.com.sailboat.todozy.utility.android.fragment.hapticHandled import br.com.sailboat.todozy.utility.android.view.gone import br.com.sailboat.todozy.utility.android.view.setSafeClickListener import br.com.sailboat.todozy.utility.android.view.visible @@ -33,6 +29,7 @@ import br.com.sailboat.uicomponent.impl.dialog.twooptions.TwoOptionsDialog import br.com.sailboat.uicomponent.impl.helper.getTaskId import br.com.sailboat.uicomponent.impl.helper.putTaskId import br.com.sailboat.uicomponent.impl.progress.TaskProgressHeaderAdapter +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import br.com.sailboat.todozy.feature.task.details.impl.R as TaskDetailsR @@ -96,7 +93,6 @@ internal class TaskDetailsFragment : Fragment() { } private fun initViews() { - binding.toolbar.setTitle(UiR.string.task_details) val fabLabel = getString(TaskDetailsR.string.fab_edit_task) binding.fab.root.text = fabLabel binding.fab.root.setIconResource(UiR.drawable.ic_edit_white_24dp) @@ -124,12 +120,17 @@ internal class TaskDetailsFragment : Fragment() { layoutManager = LinearLayoutManager(activity) } - (activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar) - binding.toolbar.setNavigationIcon(UiR.drawable.ic_arrow_back_white_24dp) - binding.toolbar.setNavigationOnClickListener { - requireActivity().onBackPressedDispatcher.onBackPressed() + binding.taskDetailsTopBar.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + binding.taskDetailsTopBar.setContent { + TodozyTheme { + val taskMetrics by viewModel.viewState.taskMetrics.observeAsState() + TaskDetailsTopBar( + taskMetrics = taskMetrics, + onNavigateBack = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onClickDelete = { viewModel.dispatchViewIntent(TaskDetailsViewIntent.OnClickMenuDelete) }, + ) + } } - addMenuProvider() } private fun observeViewModel() { @@ -146,9 +147,6 @@ internal class TaskDetailsFragment : Fragment() { viewModel.viewState.taskDetails.observe(viewLifecycleOwner) { items -> taskDetailsAdapter?.submitList(items) } - viewModel.viewState.taskMetrics.observe(viewLifecycleOwner) { taskMetrics -> - taskMetrics?.run { showMetrics(this) } ?: hideMetrics() - } viewModel.viewState.taskProgressDays.observe(viewLifecycleOwner) { renderProgress() } viewModel.viewState.taskProgressDayOrder.observe(viewLifecycleOwner) { renderProgress() } viewModel.viewState.taskProgressRange.observe(viewLifecycleOwner) { renderProgress() } @@ -195,52 +193,6 @@ internal class TaskDetailsFragment : Fragment() { Toast.makeText(activity, UiR.string.msg_error, Toast.LENGTH_SHORT).show() } - private fun addMenuProvider() { - requireActivity().addMenuProvider( - object : MenuProvider { - override fun onCreateMenu( - menu: Menu, - menuInflater: MenuInflater, - ) { - menuInflater.inflate(TaskDetailsR.menu.menu_task_details, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - TaskDetailsR.id.menu_delete -> { - return hapticHandled { - viewModel.dispatchViewIntent(TaskDetailsViewIntent.OnClickMenuDelete) - } - } - else -> false - } - } - }, - viewLifecycleOwner, - ) - } - - private fun showMetrics(taskMetrics: TaskMetrics) { - binding.taskMetrics.tvMetricsDone.text = taskMetrics.doneTasks.toString() - binding.taskMetrics.tvMetricsNotDone.text = taskMetrics.notDoneTasks.toString() - binding.taskMetrics.tvMetricsFire.text = taskMetrics.consecutiveDone.toString() - - if (taskMetrics.consecutiveDone == 0) { - binding.taskMetrics.taskMetricsLlFire.gone() - } else { - binding.taskMetrics.taskMetricsLlFire.visible() - } - - binding.appbar.setExpanded(true, true) - binding.appbarTaskDetailsFlMetrics.visible() - binding.taskMetrics.root.visible() - } - - private fun hideMetrics() { - binding.taskMetrics.root.gone() - binding.appbarTaskDetailsFlMetrics.gone() - } - private fun renderProgress() { val progressDays = viewModel.viewState.taskProgressDays.value.orEmpty() val range = viewModel.viewState.taskProgressRange.value ?: TaskProgressRange.LAST_YEAR diff --git a/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/TaskDetailsTopBar.kt b/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/TaskDetailsTopBar.kt new file mode 100644 index 00000000..94de34a5 --- /dev/null +++ b/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/TaskDetailsTopBar.kt @@ -0,0 +1,105 @@ +package br.com.sailboat.todozy.feature.task.details.impl.presentation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +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.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import br.com.sailboat.todozy.domain.model.TaskMetrics +import br.com.sailboat.uicomponent.impl.metrics.TaskMetricsRow +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing +import br.com.sailboat.uicomponent.impl.R as UiR + +@Composable +internal fun TaskDetailsTopBar( + taskMetrics: TaskMetrics?, + onNavigateBack: () -> Unit, + onClickDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + val spacing = LocalTodozySpacing.current + var isMenuExpanded by remember { mutableStateOf(false) } + Surface( + color = colorResource(id = UiR.color.md_blue_500), + elevation = 4.dp, + modifier = modifier, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(vertical = spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(id = UiR.drawable.ic_arrow_back_white_24dp), + contentDescription = null, + tint = Color.White, + ) + } + if (taskMetrics == null) { + Text( + text = stringResource(id = UiR.string.task_details), + style = MaterialTheme.typography.h6, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } else { + TaskMetricsRow( + taskMetrics = taskMetrics, + modifier = + Modifier + .weight(1f) + .padding(horizontal = spacing.small), + ) + } + Box { + IconButton(onClick = { isMenuExpanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null, + tint = Color.White, + ) + } + DropdownMenu( + expanded = isMenuExpanded, + onDismissRequest = { isMenuExpanded = false }, + ) { + DropdownMenuItem( + onClick = { + isMenuExpanded = false + onClickDelete() + }, + ) { + Text(text = stringResource(id = UiR.string.delete)) + } + } + } + } + } +} diff --git a/feature/task-details/impl/src/main/res/layout/frg_task_details.xml b/feature/task-details/impl/src/main/res/layout/frg_task_details.xml index b7245d51..6d9d0280 100644 --- a/feature/task-details/impl/src/main/res/layout/frg_task_details.xml +++ b/feature/task-details/impl/src/main/res/layout/frg_task_details.xml @@ -12,29 +12,11 @@ android:layout_height="wrap_content" android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"> - - - - - - - - + android:minHeight="?attr/actionBarSize" /> Edit Task + Metrics diff --git a/feature/task-list/impl/build.gradle.kts b/feature/task-list/impl/build.gradle.kts index f4a42bfe..61d6cd9f 100644 --- a/feature/task-list/impl/build.gradle.kts +++ b/feature/task-list/impl/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.compose.lifecycle.runtime.compose) implementation(libs.koin.android) implementation(libs.androidx.appcompat) implementation(libs.androidx.recyclerview) 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 56683ad2..98e4eb43 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 @@ -7,12 +7,15 @@ import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import br.com.sailboat.todozy.domain.model.TaskProgressRange import br.com.sailboat.todozy.feature.navigation.android.AboutNavigator import br.com.sailboat.todozy.feature.navigation.android.HomeDestination @@ -25,6 +28,7 @@ import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.Task import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewIntent import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewModel import br.com.sailboat.uicomponent.impl.helper.NotificationHelper +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import br.com.sailboat.uicomponent.impl.R as UiR @@ -56,14 +60,14 @@ internal class TaskListFragment : Fragment() { ) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val tasksLoading by viewModel.viewState.tasksLoading.observeAsState(false) - val items by viewModel.viewState.itemsView.observeAsState(mutableListOf()) - val taskMetrics by viewModel.viewState.taskMetrics.observeAsState() - val taskProgressDays by viewModel.viewState.taskProgressDays.observeAsState(emptyList()) - val taskProgressRange by viewModel.viewState.taskProgressRange.observeAsState( + val tasksLoading by viewModel.viewState.tasksLoading.collectAsStateWithLifecycle(false) + val items by viewModel.viewState.itemsView.collectAsStateWithLifecycle(emptyList()) + val taskMetrics by viewModel.viewState.taskMetrics.collectAsStateWithLifecycle() + val taskProgressDays by viewModel.viewState.taskProgressDays.collectAsStateWithLifecycle(emptyList()) + val taskProgressRange by viewModel.viewState.taskProgressRange.collectAsStateWithLifecycle( TaskProgressRange.LAST_YEAR, ) - val taskProgressLoading by viewModel.viewState.taskProgressLoading.observeAsState(false) + val taskProgressLoading by viewModel.viewState.taskProgressLoading.collectAsStateWithLifecycle(false) val haptics = LocalHapticFeedback.current TaskListScreen( @@ -129,16 +133,21 @@ internal class TaskListFragment : Fragment() { } private fun observeActions() { - viewModel.viewState.viewAction.observe(viewLifecycleOwner) { viewAction -> - when (viewAction) { - is TaskListViewAction.CloseNotifications -> closeNotifications() - is TaskListViewAction.NavigateToAbout -> navigateToAbout() - is TaskListViewAction.NavigateToHistory -> navigateToHistory() - is TaskListViewAction.NavigateToSettings -> navigateToSettings() - is TaskListViewAction.NavigateToTaskForm -> navigateToTaskForm() - is TaskListViewAction.NavigateToTaskDetails -> navigateToTaskDetails(viewAction.taskId) - is TaskListViewAction.ShowErrorCompletingTask -> showErrorCompletingTask() - is TaskListViewAction.ShowErrorLoadingTasks -> showErrorLoadingTasks() + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewState.viewAction.collect { viewAction -> + when (viewAction) { + is TaskListViewAction.CloseNotifications -> closeNotifications() + is TaskListViewAction.NavigateToAbout -> navigateToAbout() + is TaskListViewAction.NavigateToHistory -> navigateToHistory() + is TaskListViewAction.NavigateToSettings -> navigateToSettings() + is TaskListViewAction.NavigateToTaskForm -> navigateToTaskForm() + is TaskListViewAction.NavigateToTaskDetails -> navigateToTaskDetails(viewAction.taskId) + is TaskListViewAction.ShowErrorCompletingTask -> showErrorCompletingTask() + is TaskListViewAction.ShowErrorLoadingTasks -> showErrorLoadingTasks() + } + viewModel.viewState.viewAction.resetReplayCache() + } } } } 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 12631956..085c2e85 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 @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.statusBarsPadding 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 @@ -64,7 +63,6 @@ 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 androidx.compose.ui.unit.IntOffset @@ -75,11 +73,11 @@ import br.com.sailboat.todozy.domain.model.TaskProgressRange import br.com.sailboat.todozy.domain.model.TaskStatus import br.com.sailboat.todozy.feature.task.list.impl.R import br.com.sailboat.todozy.utility.kotlin.extension.isTrue +import br.com.sailboat.uicomponent.impl.metrics.TaskMetricsRow import br.com.sailboat.uicomponent.impl.progress.TaskProgressContent import br.com.sailboat.uicomponent.impl.skeleton.TaskSkeletonItem import br.com.sailboat.uicomponent.impl.subhead.SubheadItem import br.com.sailboat.uicomponent.impl.task.TaskItem -import br.com.sailboat.uicomponent.impl.theme.LocalTodozySemanticColors import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import br.com.sailboat.uicomponent.model.SubheadUiModel @@ -327,7 +325,10 @@ private fun TaskListTopBar( taskMetrics?.let { Spacer(modifier = Modifier.height(spacing.small)) - MetricsRow(taskMetrics = it) + TaskMetricsRow( + taskMetrics = it, + modifier = Modifier.fillMaxWidth(), + ) } } } @@ -358,82 +359,6 @@ private fun SwipeableTaskItem( ) } -@Composable -private fun MetricsRow(taskMetrics: TaskMetrics) { - val spacing = LocalTodozySpacing.current - val semanticColors = LocalTodozySemanticColors.current - val doneTint = colorResource(id = UiR.color.md_teal_300) - val notDoneTint = colorResource(id = UiR.color.md_red_300) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.medium), - ) { - if (taskMetrics.consecutiveDone > 0) { - MetricIconWithValue( - iconId = UiR.drawable.ic_fire_black_24dp, - tint = semanticColors.warning, - value = taskMetrics.consecutiveDone, - modifier = Modifier.weight(1f), - ) - } - MetricIconWithValue( - iconId = UiR.drawable.ic_vec_thumb_up_white_24dp, - tint = doneTint, - value = taskMetrics.doneTasks, - modifier = Modifier.weight(1f), - ) - MetricIconWithValue( - iconId = UiR.drawable.ic_vect_thumb_down_white_24dp, - tint = notDoneTint, - value = taskMetrics.notDoneTasks, - modifier = Modifier.weight(1f), - ) - } -} - -@Composable -private fun MetricIconWithValue( - iconId: Int, - tint: Color, - value: Int, - modifier: Modifier = Modifier, -) { - val spacing = LocalTodozySpacing.current - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.xsmall), - ) { - Surface( - shape = CircleShape, - color = Color.White, - elevation = 0.dp, - ) { - Box( - modifier = - Modifier - .size(28.dp) - .padding(spacing.xxsmall), - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(id = iconId), - contentDescription = null, - tint = tint, - modifier = Modifier.height(18.dp), - ) - } - } - Text( - text = value.toString(), - style = MaterialTheme.typography.subtitle1, - color = Color.White, - fontWeight = FontWeight.Bold, - ) - } -} - @Composable private fun EmptyState() { val spacing = LocalTodozySpacing.current 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 a8f0705c..34d09438 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 @@ -117,14 +117,14 @@ internal class TaskListViewModel( private suspend fun performFullLoad(closeNotifications: Boolean = false) { try { - viewState.tasksLoading.postValue(true) - viewState.taskProgressLoading.postValue(true) + viewState.tasksLoading.value = true + viewState.taskProgressLoading.value = true clearProgressCache() if (closeNotifications) { - viewState.viewAction.postValue(TaskListViewAction.CloseNotifications) + viewState.viewAction.tryEmit(TaskListViewAction.CloseNotifications) } loadTasks() - viewState.tasksLoading.postValue(false) + viewState.tasksLoading.value = false loadTaskMetrics() loadProgress() scheduleAllAlarmsUseCase() @@ -132,30 +132,30 @@ internal class TaskListViewModel( hasLoaded = true } catch (e: Exception) { logService.error(e) - viewState.viewAction.value = TaskListViewAction.ShowErrorLoadingTasks + viewState.viewAction.tryEmit(TaskListViewAction.ShowErrorLoadingTasks) } finally { - viewState.taskProgressLoading.postValue(false) + viewState.taskProgressLoading.value = false } } private fun onClickMenuAbout() { - viewState.viewAction.value = TaskListViewAction.NavigateToAbout + viewState.viewAction.tryEmit(TaskListViewAction.NavigateToAbout) } private fun onClickMenuSettings() { - viewState.viewAction.value = TaskListViewAction.NavigateToSettings + viewState.viewAction.tryEmit(TaskListViewAction.NavigateToSettings) } private fun onClickMenuHistory() { - viewState.viewAction.value = TaskListViewAction.NavigateToHistory + viewState.viewAction.tryEmit(TaskListViewAction.NavigateToHistory) } private fun onClickNewTask() { - viewState.viewAction.value = TaskListViewAction.NavigateToTaskForm + viewState.viewAction.tryEmit(TaskListViewAction.NavigateToTaskForm) } private fun onClickTask(taskId: Long) { - viewState.viewAction.value = TaskListViewAction.NavigateToTaskDetails(taskId = taskId) + viewState.viewAction.tryEmit(TaskListViewAction.NavigateToTaskDetails(taskId = taskId)) } private fun onSubmitSearchTerm(term: String) { @@ -163,8 +163,8 @@ internal class TaskListViewModel( searchJob = viewModelScope.launch { try { delay(SEARCH_DEBOUNCE_IN_MILLIS) - viewState.tasksLoading.postValue(true) - viewState.taskProgressLoading.postValue(true) + viewState.tasksLoading.value = true + viewState.taskProgressLoading.value = true val hasQueryChanged = taskFilter.text != term taskFilter = taskFilter.copy(text = term) if (hasQueryChanged) { @@ -177,10 +177,10 @@ internal class TaskListViewModel( return@launch } catch (e: Exception) { logService.error(e) - viewState.viewAction.value = TaskListViewAction.ShowErrorLoadingTasks + viewState.viewAction.tryEmit(TaskListViewAction.ShowErrorLoadingTasks) } finally { - viewState.tasksLoading.postValue(false) - viewState.taskProgressLoading.postValue(false) + viewState.tasksLoading.value = false + viewState.taskProgressLoading.value = false } } } @@ -190,7 +190,7 @@ internal class TaskListViewModel( return } selectedProgressRange = range - viewState.taskProgressRange.postValue(range) + viewState.taskProgressRange.value = range loadProgress() viewModelScope.launch { loadTaskMetrics() } } @@ -199,18 +199,18 @@ internal class TaskListViewModel( if (currentTasks.isEmpty()) { progressJob?.cancel() progressCache.remove(currentProgressCacheKey()) - viewState.taskProgressLoading.postValue(false) - viewState.taskProgressRange.postValue(selectedProgressRange) - viewState.taskProgressDays.postValue(emptyList()) + viewState.taskProgressLoading.value = false + viewState.taskProgressRange.value = selectedProgressRange + viewState.taskProgressDays.value = emptyList() return } val cacheKey = currentProgressCacheKey() val cachedProgress = progressCache[cacheKey] if (cachedProgress != null && force.not()) { - viewState.taskProgressRange.postValue(selectedProgressRange) - viewState.taskProgressDays.postValue(cachedProgress) - viewState.taskProgressLoading.postValue(false) + viewState.taskProgressRange.value = selectedProgressRange + viewState.taskProgressDays.value = cachedProgress + viewState.taskProgressLoading.value = false return } @@ -218,7 +218,7 @@ internal class TaskListViewModel( progressJob = viewModelScope.launch { try { - viewState.taskProgressLoading.postValue(true) + viewState.taskProgressLoading.value = true val progressFilter = TaskProgressFilter( @@ -230,12 +230,12 @@ internal class TaskListViewModel( getTaskProgressUseCase(progressFilter).getOrThrow() } progressCache[cacheKey] = progress - viewState.taskProgressRange.postValue(selectedProgressRange) - viewState.taskProgressDays.postValue(progress) + viewState.taskProgressRange.value = selectedProgressRange + viewState.taskProgressDays.value = progress } catch (e: Exception) { logService.error(e) } finally { - viewState.taskProgressLoading.postValue(false) + viewState.taskProgressLoading.value = false } } } @@ -262,7 +262,7 @@ internal class TaskListViewModel( currentTasks = taskCategories.flatMap { category -> domainTasksByCategory[category].orEmpty() } - viewState.itemsView.postValue(itemsWithFeedback.toMutableList()) + viewState.itemsView.value = itemsWithFeedback.toMutableList() } private fun refreshTasksFromCache() { @@ -276,7 +276,7 @@ internal class TaskListViewModel( }.flatten() val itemsWithFeedback = applyInlineFeedbacks(uiModels) - viewState.itemsView.postValue(itemsWithFeedback.toMutableList()) + viewState.itemsView.value = itemsWithFeedback.toMutableList() } private suspend fun loadTaskMetrics() { @@ -286,8 +286,7 @@ internal class TaskListViewModel( currentTasks.map { it.id } } else { viewState.itemsView.value - ?.mapNotNull { (it as? TaskUiModel)?.taskId } - .orEmpty() + .mapNotNull { (it as? TaskUiModel)?.taskId } } if (taskIds.isEmpty()) { @@ -330,7 +329,7 @@ internal class TaskListViewModel( }.onSuccess { metrics -> if (metrics == null) { baseTaskMetrics = null - viewState.taskMetrics.postValue(null) + viewState.taskMetrics.value = null } else { baseTaskMetrics = metrics publishTaskMetricsWithPending() @@ -339,7 +338,7 @@ internal class TaskListViewModel( logService.error(throwable) baseTaskMetrics = null baseTaskConsecutive.clear() - viewState.taskMetrics.postValue(null) + viewState.taskMetrics.value = null } } @@ -375,7 +374,7 @@ internal class TaskListViewModel( publishTaskMetricsWithPending() } catch (e: Exception) { logService.error(e) - viewState.viewAction.value = TaskListViewAction.ShowErrorCompletingTask + viewState.viewAction.tryEmit(TaskListViewAction.ShowErrorCompletingTask) } } @@ -388,9 +387,9 @@ internal class TaskListViewModel( logService.error(throwable) }.getOrNull() - private fun publishItemsWithInlineFeedback(baseItems: List? = viewState.itemsView.value) { - val updatedItems = applyInlineFeedbacks(baseItems.orEmpty()) - viewState.itemsView.postValue(updatedItems.toMutableList()) + private fun publishItemsWithInlineFeedback(baseItems: List = viewState.itemsView.value) { + val updatedItems = applyInlineFeedbacks(baseItems) + viewState.itemsView.value = updatedItems.toMutableList() } private fun applyInlineFeedbacks(items: List): List { @@ -436,7 +435,7 @@ internal class TaskListViewModel( private fun publishTaskMetricsWithPending() { val pending = inlineTaskFeedbacks.values if (pending.isEmpty() && baseTaskMetrics == null) { - viewState.taskMetrics.postValue(null) + viewState.taskMetrics.value = null return } @@ -459,13 +458,12 @@ internal class TaskListViewModel( } } - viewState.taskMetrics.postValue( + viewState.taskMetrics.value = TaskMetrics( doneTasks = doneTasks, notDoneTasks = notDoneTasks, consecutiveDone = consecutiveDone, - ), - ) + ) } private fun scheduleInlineFeedbackCommit(taskId: Long) { @@ -496,7 +494,7 @@ internal class TaskListViewModel( publishTaskMetricsWithPending() }.onFailure { throwable -> logService.error(throwable) - viewState.viewAction.postValue(TaskListViewAction.ShowErrorCompletingTask) + viewState.viewAction.tryEmit(TaskListViewAction.ShowErrorCompletingTask) publishItemsWithInlineFeedback() publishTaskMetricsWithPending() } @@ -528,7 +526,7 @@ internal class TaskListViewModel( } private fun removeTaskFromList(taskId: Long) { - val itemsView = viewState.itemsView.value?.toMutableList() ?: mutableListOf() + val itemsView = viewState.itemsView.value.toMutableList() val position = itemsView.indexOfFirst { (it as? TaskUiModel)?.taskId == taskId } if (position >= 0) { @@ -551,7 +549,7 @@ internal class TaskListViewModel( ) { viewModelScope.launch { try { - val itemsView = viewState.itemsView.value ?: return@launch + val itemsView = viewState.itemsView.value val taskUiModel = itemsView.firstOrNull { (it as? TaskUiModel)?.taskId == taskId } as? TaskUiModel ?: return@launch @@ -572,7 +570,7 @@ internal class TaskListViewModel( scheduleInlineFeedbackCommit(taskUiModel.taskId) } catch (e: Exception) { logService.error(e) - viewState.viewAction.value = TaskListViewAction.ShowErrorCompletingTask + viewState.viewAction.tryEmit(TaskListViewAction.ShowErrorCompletingTask) } } } @@ -594,8 +592,8 @@ internal class TaskListViewModel( progressCache.putAll(updatedCache) progressCache[cacheKey]?.let { updatedDays -> - viewState.taskProgressDays.postValue(updatedDays) - viewState.taskProgressRange.postValue(selectedProgressRange) + viewState.taskProgressDays.value = updatedDays + viewState.taskProgressRange.value = selectedProgressRange } } diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewState.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewState.kt index e918c1fb..3f631241 100644 --- a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewState.kt +++ b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewState.kt @@ -1,18 +1,18 @@ package br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel -import androidx.lifecycle.MutableLiveData import br.com.sailboat.todozy.domain.model.TaskMetrics import br.com.sailboat.todozy.domain.model.TaskProgressDay import br.com.sailboat.todozy.domain.model.TaskProgressRange -import br.com.sailboat.todozy.utility.android.livedata.Event import br.com.sailboat.uicomponent.model.UiModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow internal class TaskListViewState { - val viewAction = Event() - val tasksLoading = MutableLiveData(false) - val itemsView = MutableLiveData>() - val taskMetrics = MutableLiveData() - val taskProgressDays = MutableLiveData>(emptyList()) - val taskProgressRange = MutableLiveData(TaskProgressRange.LAST_YEAR) - val taskProgressLoading = MutableLiveData(false) + val viewAction = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val tasksLoading = MutableStateFlow(false) + val itemsView = MutableStateFlow>(emptyList()) + val taskMetrics = MutableStateFlow(null) + val taskProgressDays = MutableStateFlow>(emptyList()) + val taskProgressRange = MutableStateFlow(TaskProgressRange.LAST_YEAR) + val taskProgressLoading = MutableStateFlow(false) } 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 b71f59f8..b877710e 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 @@ -81,7 +81,7 @@ internal class TaskListViewModelTest { assertEquals( TaskListViewAction.CloseNotifications, - viewModel.viewState.viewAction.value, + latestAction(), ) } @@ -191,7 +191,7 @@ internal class TaskListViewModelTest { viewModel.dispatchViewIntent(TaskListViewIntent.OnClickMenuAbout) - assertEquals(TaskListViewAction.NavigateToAbout, viewModel.viewState.viewAction.value) + assertEquals(TaskListViewAction.NavigateToAbout, latestAction()) } @Test @@ -200,7 +200,7 @@ internal class TaskListViewModelTest { viewModel.dispatchViewIntent(TaskListViewIntent.OnClickMenuHistory) - assertEquals(TaskListViewAction.NavigateToHistory, viewModel.viewState.viewAction.value) + assertEquals(TaskListViewAction.NavigateToHistory, latestAction()) } @Test @@ -209,7 +209,7 @@ internal class TaskListViewModelTest { viewModel.dispatchViewIntent(TaskListViewIntent.OnClickMenuSettings) - assertEquals(TaskListViewAction.NavigateToSettings, viewModel.viewState.viewAction.value) + assertEquals(TaskListViewAction.NavigateToSettings, latestAction()) } @Test @@ -217,7 +217,7 @@ internal class TaskListViewModelTest { prepareScenario() viewModel.dispatchViewIntent(TaskListViewIntent.OnClickNewTask) - assertEquals(TaskListViewAction.NavigateToTaskForm, viewModel.viewState.viewAction.value) + assertEquals(TaskListViewAction.NavigateToTaskForm, latestAction()) } @Test @@ -228,7 +228,7 @@ internal class TaskListViewModelTest { viewModel.dispatchViewIntent(TaskListViewIntent.OnClickTask(taskId = taskId)) val expected = TaskListViewAction.NavigateToTaskDetails(taskId = taskId) - assertEquals(expected, viewModel.viewState.viewAction.value) + assertEquals(expected, latestAction()) } @Test @@ -586,4 +586,6 @@ internal class TaskListViewModelTest { coEvery { getTaskMetricsUseCase(any()) } returns Result.success(TaskMetrics(0, 0, 0)) coEvery { completeTaskUseCase(any(), any()) } returns Result.success(Unit) } + + private fun latestAction(): TaskListViewAction? = viewModel.viewState.viewAction.replayCache.lastOrNull() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da9ed386..8c9ce72f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -110,6 +110,7 @@ androidx-compose-material = { module = "androidx.compose.material:material", ver androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "composeMaterialIcons" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata", version.ref = "compose" } +androidx-compose-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "composeLifecycle" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } androidx-compose-activity = { module = "androidx.activity:activity-compose", version.ref = "composeActivity" } androidx-compose-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "composeLifecycle" } diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/metrics/TaskMetricsRow.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/metrics/TaskMetricsRow.kt new file mode 100644 index 00000000..61a0ec60 --- /dev/null +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/metrics/TaskMetricsRow.kt @@ -0,0 +1,106 @@ +package br.com.sailboat.uicomponent.impl.metrics + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import br.com.sailboat.todozy.domain.model.TaskMetrics +import br.com.sailboat.uicomponent.impl.R +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySemanticColors +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing + +@Composable +fun TaskMetricsRow( + taskMetrics: TaskMetrics, + modifier: Modifier = Modifier, + distributeEvenly: Boolean = true, +) { + val spacing = LocalTodozySpacing.current + val semanticColors = LocalTodozySemanticColors.current + val doneTint = colorResource(id = R.color.md_teal_300) + val notDoneTint = colorResource(id = R.color.md_red_300) + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.medium), + ) { + val metricModifier = if (distributeEvenly) Modifier.weight(1f) else Modifier + if (taskMetrics.consecutiveDone > 0) { + MetricIconWithValue( + iconId = R.drawable.ic_fire_black_24dp, + tint = semanticColors.warning, + value = taskMetrics.consecutiveDone, + modifier = metricModifier, + ) + } + MetricIconWithValue( + iconId = R.drawable.ic_vec_thumb_up_white_24dp, + tint = doneTint, + value = taskMetrics.doneTasks, + modifier = metricModifier, + ) + MetricIconWithValue( + iconId = R.drawable.ic_vect_thumb_down_white_24dp, + tint = notDoneTint, + value = taskMetrics.notDoneTasks, + modifier = metricModifier, + ) + } +} + +@Composable +private fun MetricIconWithValue( + iconId: Int, + tint: Color, + value: Int, + modifier: Modifier = Modifier, +) { + val spacing = LocalTodozySpacing.current + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.xsmall), + ) { + Surface( + shape = CircleShape, + color = Color.White, + elevation = 0.dp, + ) { + Box( + modifier = + Modifier + .size(28.dp) + .padding(spacing.xxsmall), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = iconId), + contentDescription = null, + tint = tint, + modifier = Modifier.height(18.dp), + ) + } + } + Text( + text = value.toString(), + style = MaterialTheme.typography.subtitle1, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } +}