From 9b9c7d995ec8465176ffcc8713d4e3a185e9581b Mon Sep 17 00:00:00 2001 From: Brayan Bedritchuk Date: Tue, 9 Dec 2025 22:53:42 -0300 Subject: [PATCH] Add Compose swipe to dismiss --- .../impl/presentation/TaskDetailsFragment.kt | 5 +- .../list/impl/presentation/TaskListScreen.kt | 243 ++++++++++++------ .../viewmodel/TaskListViewModel.kt | 150 ++++++----- .../viewmodel/TaskListViewModelTest.kt | 2 + 4 files changed, 244 insertions(+), 156 deletions(-) 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 223fc8e0..98a1ef02 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 @@ -56,7 +56,10 @@ internal class TaskDetailsFragment : Fragment() { private var deleteTaskDialog: TwoOptionsDialog? = null private val editTaskLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + activity?.setResult(Activity.RESULT_OK) + } viewModel.dispatchViewIntent(TaskDetailsViewIntent.OnReturnToDetails) } 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 0647a301..12631956 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 @@ -1,6 +1,15 @@ package br.com.sailboat.todozy.feature.task.list.impl.presentation +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.AnchoredDraggableDefaults +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.snapTo import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,6 +19,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -22,13 +32,11 @@ import androidx.compose.material.DismissDirection import androidx.compose.material.DismissValue import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExtendedFloatingActionButton -import androidx.compose.material.FractionalThreshold import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface -import androidx.compose.material.SwipeToDismiss import androidx.compose.material.Text import androidx.compose.material.TextField import androidx.compose.material.TextFieldDefaults @@ -39,6 +47,7 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -48,6 +57,8 @@ 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.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.colorResource @@ -55,6 +66,8 @@ 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 import androidx.compose.ui.unit.dp import br.com.sailboat.todozy.domain.model.TaskMetrics import br.com.sailboat.todozy.domain.model.TaskProgressDay @@ -73,9 +86,12 @@ import br.com.sailboat.uicomponent.model.SubheadUiModel import br.com.sailboat.uicomponent.model.TaskSkeletonUiModel import br.com.sailboat.uicomponent.model.TaskUiModel import br.com.sailboat.uicomponent.model.UiModel +import kotlin.math.roundToInt import br.com.sailboat.todozy.utility.android.R as AndroidUtilR import br.com.sailboat.uicomponent.impl.R as UiR +private const val SWIPE_DISMISS_THRESHOLD_FRACTION = 0.6f + @Composable internal fun TaskListScreen( tasksLoading: Boolean, @@ -317,7 +333,7 @@ private fun TaskListTopBar( } } -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) @Composable private fun SwipeableTaskItem( task: TaskUiModel, @@ -327,83 +343,19 @@ private fun SwipeableTaskItem( modifier: Modifier = Modifier, ) { val spacing = LocalTodozySpacing.current - val dismissState = androidx.compose.material.rememberDismissState() - - LaunchedEffect(task.taskId, task.inlineStatus, task.showInlineMetrics) { - dismissState.snapTo(DismissValue.Default) - } - - LaunchedEffect(dismissState.currentValue) { - when (dismissState.currentValue) { - DismissValue.DismissedToEnd -> { - onSwipe(TaskStatus.DONE) - dismissState.reset() - } + val baseModifier = modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(durationMillis = 200)) - DismissValue.DismissedToStart -> { - onSwipe(TaskStatus.NOT_DONE) - dismissState.reset() - } - - else -> Unit - } - } - - if (task.showInlineMetrics) { - TaskItem( - task = task, - onClick = onClick, - onUndoClick = onUndoClick, - modifier = modifier, - ) - } else { - SwipeToDismiss( - state = dismissState, - directions = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart), - 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) - - DismissDirection.EndToStart -> - UiR.drawable.ic_vect_thumb_down_white_24dp to colorResource(id = UiR.color.md_red_200) - - else -> null to Color.Transparent - } - - if (icon != null) { - Row( - modifier = Modifier - .fillMaxSize() - .background(backgroundColor) - .padding(horizontal = spacing.medium), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = - when (direction) { - DismissDirection.StartToEnd -> Arrangement.Start - DismissDirection.EndToStart -> Arrangement.End - else -> Arrangement.Start - }, - ) { - Icon( - painter = painterResource(id = icon), - contentDescription = null, - tint = Color.White, - ) - } - } - }, - modifier = modifier, - ) { - TaskItem( - task = task, - onClick = onClick, - onUndoClick = onUndoClick, - ) - } - } + AnchoredSwipeToDismiss( + task = task, + modifier = baseModifier, + spacingHorizontal = spacing.medium, + onSwipe = onSwipe, + onClick = onClick, + onUndoClick = onUndoClick, + enableSwipe = task.showInlineMetrics.not(), + ) } @Composable @@ -514,3 +466,136 @@ private fun stableTaskListKey( is SubheadUiModel -> "subhead-${item.subhead}" else -> "${item.uiModelId}-$index" } + +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@Composable +private fun AnchoredSwipeToDismiss( + task: TaskUiModel, + modifier: Modifier = Modifier, + spacingHorizontal: Dp, + onSwipe: (TaskStatus) -> Unit, + onClick: (Long) -> Unit, + onUndoClick: (Long, TaskStatus) -> Unit, + enableSwipe: Boolean, +) { + var widthPx by remember { mutableFloatStateOf(0f) } + var heightPx by remember { mutableFloatStateOf(0f) } + val density = LocalDensity.current + + val anchors = remember(widthPx) { + if (widthPx == 0f) { + DraggableAnchors { DismissValue.Default at 0f } + } else { + DraggableAnchors { + DismissValue.Default at 0f + DismissValue.DismissedToStart at -widthPx + DismissValue.DismissedToEnd at widthPx + } + } + } + + val dismissState = remember { AnchoredDraggableState(DismissValue.Default) } + val flingBehavior = AnchoredDraggableDefaults.flingBehavior( + state = dismissState, + positionalThreshold = { distance -> distance * SWIPE_DISMISS_THRESHOLD_FRACTION }, + ) + + LaunchedEffect(anchors) { + dismissState.updateAnchors(anchors) + } + + LaunchedEffect(task.taskId, task.inlineStatus, task.showInlineMetrics) { + dismissState.snapTo(DismissValue.Default) + } + + LaunchedEffect(dismissState.currentValue) { + when (dismissState.currentValue) { + DismissValue.DismissedToEnd -> { + onSwipe(TaskStatus.DONE) + dismissState.snapTo(DismissValue.Default) + } + + DismissValue.DismissedToStart -> { + onSwipe(TaskStatus.NOT_DONE) + dismissState.snapTo(DismissValue.Default) + } + + else -> Unit + } + } + + val offsetX = dismissState.offset.takeIf { !it.isNaN() } ?: 0f + val direction = + when { + offsetX > 0f -> DismissDirection.StartToEnd + offsetX < 0f -> DismissDirection.EndToStart + else -> null + } + + Box( + modifier = + modifier + .fillMaxWidth() + .onSizeChanged { size -> + widthPx = size.width.toFloat() + heightPx = size.height.toFloat() + }, + ) { + 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) + + else -> null to Color.Transparent + } + + if (icon != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .let { base -> + if (heightPx > 0f) { + base.height(with(density) { heightPx.toDp() }) + } else { + base + } + } + .background(backgroundColor) + .padding(horizontal = spacingHorizontal), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = + when (direction) { + DismissDirection.StartToEnd -> Arrangement.Start + DismissDirection.EndToStart -> Arrangement.End + else -> Arrangement.Start + }, + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = Color.White, + ) + } + } + + TaskItem( + task = task, + onClick = onClick, + onUndoClick = onUndoClick, + modifier = + Modifier + .fillMaxWidth() + .offset { IntOffset(offsetX.roundToInt(), 0) } + .anchoredDraggable( + state = dismissState, + orientation = Orientation.Horizontal, + enabled = enableSwipe, + flingBehavior = flingBehavior, + ), + ) + } +} 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 266faf29..5de79e36 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 @@ -35,6 +35,7 @@ import br.com.sailboat.todozy.utility.kotlin.extension.toStartOfDayCalendar import br.com.sailboat.todozy.utility.kotlin.model.Entity import br.com.sailboat.uicomponent.model.TaskUiModel import br.com.sailboat.uicomponent.model.UiModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -45,6 +46,7 @@ import kotlinx.coroutines.withContext import java.time.LocalDate private const val TASK_SWIPE_DELAY_IN_MILLIS = 4000L +private const val SEARCH_DEBOUNCE_IN_MILLIS = 300L internal class TaskListViewModel( override val viewState: TaskListViewState = TaskListViewState(), @@ -74,6 +76,7 @@ internal class TaskListViewModel( private val inlineTaskJobs: MutableMap = mutableMapOf() private var progressJob: Job? = null private val progressCache: MutableMap> = mutableMapOf() + private var searchJob: Job? = null private var hasLoaded = false private var lastFullLoadDate: LocalDate? = null @@ -87,7 +90,7 @@ internal class TaskListViewModel( is OnClickNewTask -> onClickNewTask() is OnClickTask -> onClickTask(viewIntent.taskId) is OnSubmitSearchTerm -> onSubmitSearchTerm(viewIntent.term) - is OnClickUndoTask -> onClickUndoTask(viewIntent.taskId, viewIntent.status) + is OnClickUndoTask -> onClickUndoTask(viewIntent.taskId) is OnSwipeTask -> onSwipeTask(viewIntent.taskId, viewIntent.status) is OnSelectProgressRange -> onSelectProgressRange(viewIntent.range) } @@ -154,24 +157,30 @@ internal class TaskListViewModel( viewState.viewAction.value = TaskListViewAction.NavigateToTaskDetails(taskId = taskId) } - private fun onSubmitSearchTerm(term: String) = viewModelScope.launch { - try { - viewState.tasksLoading.postValue(true) - viewState.taskProgressLoading.postValue(true) - val hasQueryChanged = taskFilter.text != term - taskFilter = taskFilter.copy(text = term) - if (hasQueryChanged) { - clearProgressCache() + private fun onSubmitSearchTerm(term: String) { + searchJob?.cancel() + searchJob = viewModelScope.launch { + try { + delay(SEARCH_DEBOUNCE_IN_MILLIS) + viewState.tasksLoading.postValue(true) + viewState.taskProgressLoading.postValue(true) + val hasQueryChanged = taskFilter.text != term + taskFilter = taskFilter.copy(text = term) + if (hasQueryChanged) { + clearProgressCache() + } + loadTasks() + loadTaskMetrics() + loadProgress(force = true) + } catch (_: CancellationException) { + return@launch + } catch (e: Exception) { + logService.error(e) + viewState.viewAction.value = TaskListViewAction.ShowErrorLoadingTasks + } finally { + viewState.tasksLoading.postValue(false) + viewState.taskProgressLoading.postValue(false) } - loadTasks() - viewState.tasksLoading.postValue(false) - loadTaskMetrics() - loadProgress(force = true) - } catch (e: Exception) { - logService.error(e) - viewState.viewAction.value = TaskListViewAction.ShowErrorLoadingTasks - } finally { - viewState.taskProgressLoading.postValue(false) } } @@ -216,10 +225,9 @@ internal class TaskListViewModel( taskId = Entity.NO_ID, text = taskFilter.text, ) - val progress = - withContext(dispatcherProvider.default()) { - getTaskProgressUseCase(progressFilter).getOrThrow() - } + val progress = withContext(dispatcherProvider.default()) { + getTaskProgressUseCase(progressFilter).getOrThrow() + } progressCache[cacheKey] = progress viewState.taskProgressRange.postValue(selectedProgressRange) viewState.taskProgressDays.postValue(progress) @@ -233,28 +241,26 @@ internal class TaskListViewModel( private suspend fun loadTasks() = coroutineScope { val domainTasksByCategory = mutableMapOf>() - - val tasksUiModels = - taskCategories.map { category -> - async { - val filter = - TaskFilter( - text = taskFilter.text, - category = category, - ) - val tasks = - withContext(dispatcherProvider.io()) { - getTasksUseCase(filter).getOrThrow() - } - - domainTasksByCategory[category] = tasks - taskListUiModelFactory.create(tasks, category) + val tasksUiModels = taskCategories.map { category -> + async { + val filter = TaskFilter( + text = taskFilter.text, + category = category, + ) + val tasks = withContext(dispatcherProvider.io()) { + getTasksUseCase(filter).getOrThrow() } - }.awaitAll().flatten() + + domainTasksByCategory[category] = tasks + taskListUiModelFactory.create(tasks, category) + } + }.awaitAll().flatten() val itemsWithFeedback = applyInlineFeedbacks(tasksUiModels) currentTasksByCategory = domainTasksByCategory.toMap() - currentTasks = taskCategories.flatMap { category -> domainTasksByCategory[category].orEmpty() } + currentTasks = taskCategories.flatMap { category -> + domainTasksByCategory[category].orEmpty() + } viewState.itemsView.postValue(itemsWithFeedback.toMutableList()) } @@ -263,12 +269,10 @@ internal class TaskListViewModel( return } - val uiModels = - taskCategories - .map { category -> - val tasks = currentTasksByCategory[category].orEmpty() - taskListUiModelFactory.create(tasks, category) - }.flatten() + val uiModels = taskCategories.map { category -> + val tasks = currentTasksByCategory[category].orEmpty() + taskListUiModelFactory.create(tasks, category) + }.flatten() val itemsWithFeedback = applyInlineFeedbacks(uiModels) viewState.itemsView.postValue(itemsWithFeedback.toMutableList()) @@ -277,41 +281,37 @@ internal class TaskListViewModel( private suspend fun loadTaskMetrics() { runCatching { coroutineScope { - val taskIds = - if (currentTasks.isNotEmpty()) { - currentTasks.map { it.id } - } else { - viewState.itemsView.value - ?.mapNotNull { (it as? TaskUiModel)?.taskId } - .orEmpty() - } + val taskIds = if (currentTasks.isNotEmpty()) { + currentTasks.map { it.id } + } else { + viewState.itemsView.value + ?.mapNotNull { (it as? TaskUiModel)?.taskId } + .orEmpty() + } if (taskIds.isEmpty()) { baseTaskConsecutive.clear() return@coroutineScope null } - val globalMetricsDeferred = - async(dispatcherProvider.default()) { - val filter = historyFilterForRange() - getTaskMetricsUseCase(filter).getOrThrow() - } + val globalMetricsDeferred = async(dispatcherProvider.default()) { + val filter = historyFilterForRange() + getTaskMetricsUseCase(filter).getOrThrow() + } - val perTaskConsecutiveDeferred = - async(dispatcherProvider.default()) { - if (taskIds.isEmpty()) { - emptyMap() - } else { - val deferredMap = - taskIds.associateWith { taskId -> - async { - val filter = historyFilterForRange(taskId, includeText = true) - getTaskMetricsUseCase(filter).getOrThrow().consecutiveDone - } - } - deferredMap.mapValues { it.value.await() } + val perTaskConsecutiveDeferred = async(dispatcherProvider.default()) { + if (taskIds.isEmpty()) { + emptyMap() + } else { + val deferredMap = taskIds.associateWith { taskId -> + async { + val filter = historyFilterForRange(taskId, includeText = true) + getTaskMetricsUseCase(filter).getOrThrow().consecutiveDone + } } + deferredMap.mapValues { it.value.await() } } + } val globalMetrics = globalMetricsDeferred.await() val perTaskConsecutive = perTaskConsecutiveDeferred.await() @@ -366,10 +366,7 @@ internal class TaskListViewModel( ) } - private fun onClickUndoTask( - taskId: Long, - status: TaskStatus, - ) = viewModelScope.launch { + private fun onClickUndoTask(taskId: Long) = viewModelScope.launch { try { cancelInlineFeedback(taskId) inlineTaskFeedbacks.remove(taskId) @@ -452,6 +449,7 @@ internal class TaskListViewModel( doneTasks += 1 consecutiveDone += 1 } + TaskStatus.NOT_DONE -> { notDoneTasks += 1 val baseConsecutive = baseTaskConsecutive[feedback.uiModel.taskId] ?: 0 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 6d4c2484..b75e1d8e 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 @@ -36,6 +36,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue private const val TASK_SWIPE_DELAY_IN_MILLIS = 4000L +private const val SEARCH_DEBOUNCE_IN_MILLIS = 300L @ExperimentalCoroutinesApi internal class TaskListViewModelTest { @@ -243,6 +244,7 @@ internal class TaskListViewModelTest { prepareScenario(tasksView = tasksView) viewModel.dispatchViewIntent(TaskListViewIntent.OnSubmitSearchTerm(term = term)) + advanceTimeBy(SEARCH_DEBOUNCE_IN_MILLIS) advanceUntilIdle() coVerifyOrder {