From 81f348f1c34136b9900de85cdd6369ce6caeca40 Mon Sep 17 00:00:00 2001 From: Brayan Bedritchuk Date: Mon, 1 Dec 2025 08:14:06 -0300 Subject: [PATCH 1/3] Remove non-filtered days in the grid --- .../impl/progress/TaskProgressComponents.kt | 63 +++++++++++++++---- .../progress/TaskProgressGridBuilderTest.kt | 57 +++++++++++++++++ 2 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 ui-component/impl/src/test/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressGridBuilderTest.kt diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt index d80bc8bf..9461ea18 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt @@ -48,6 +48,7 @@ import java.time.DayOfWeek import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle +import java.time.temporal.TemporalAdjusters import java.util.Locale internal val DefaultTaskProgressDayOrder = @@ -209,10 +210,11 @@ private fun TaskProgressGrid( style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), modifier = Modifier.padding(top = 4.dp), - ) + ) return } + val daySpacing = 6.dp val locale = Locale.getDefault() val dayMetadata = remember(days, palette, locale) { @@ -236,8 +238,7 @@ private fun TaskProgressGrid( ) } - val paddedDays = remember(days, dayOrder) { padDays(days, dayOrder) } - val weeks = remember(paddedDays, dayOrder.size) { paddedDays.chunked(dayOrder.size) } + val weeks = remember(days, dayOrder) { buildWeekColumns(days, dayOrder) } val lastWeekIndex = remember(weeks.size) { weeks.lastIndex.coerceAtLeast(0) } val lazyListState: LazyListState = rememberSaveable(weeks.size, saver = LazyListState.Saver) { @@ -248,13 +249,19 @@ private fun TaskProgressGrid( WeekdayLabels(dayOrder = dayOrder, cellSize = cellSize) LazyRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(daySpacing), state = lazyListState, ) { items(weeks) { week -> - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - week.forEach { day -> - val metadata = day?.let { dayMetadata[it.date] } + Column(verticalArrangement = Arrangement.spacedBy(daySpacing)) { + if (week.offset > 0) { + Spacer( + modifier = + Modifier.height((cellSize + daySpacing) * week.offset - daySpacing), + ) + } + week.days.forEach { day -> + val metadata = dayMetadata[day.date] val color = metadata?.color ?: palette.neutral val semanticsDescription = metadata?.semanticsDescription @@ -265,7 +272,7 @@ private fun TaskProgressGrid( .clip(RoundedCornerShape(4.dp)) .background(color) .let { - if (day != null && onDayClick != null) { + if (onDayClick != null) { it.clickable { onDayHaptic?.invoke() onDayClick(day) @@ -278,7 +285,7 @@ private fun TaskProgressGrid( semanticsDescription?.let { desc -> contentDescription = desc } }, ) { - if (enableDayDetails && day != null && selectedDay == day) { + if (enableDayDetails && selectedDay == day) { DayTooltip( day = day, formatter = formatter, @@ -399,17 +406,47 @@ private fun WeekdayLabels( } } -private fun padDays( +internal data class WeekColumn( + val offset: Int, + val days: List, +) + +internal fun buildWeekColumns( days: List, dayOrder: List, -): List { +): List { if (days.isEmpty()) { return emptyList() } - val offset = dayOrder.indexOf(days.first().date.dayOfWeek).coerceAtLeast(0) + val sortedDays = days.sortedBy { it.date } + val weekStartDay = dayOrder.firstOrNull() ?: DayOfWeek.MONDAY + val daysByWeekStart = + sortedDays.groupBy { day -> + day.date.with(TemporalAdjusters.previousOrSame(weekStartDay)) + } + + return daysByWeekStart + .toSortedMap() + .values + .map { weekDays -> + val sortedWeekDays = + weekDays.sortedWith( + compareBy( + { day -> + val index = dayOrder.indexOf(day.date.dayOfWeek) + if (index >= 0) index else day.date.dayOfWeek.value + }, + { day -> day.date }, + ), + ) + val offset = dayOrder.indexOf(sortedWeekDays.first().date.dayOfWeek).coerceAtLeast(0) - return List(offset) { null } + days + WeekColumn( + offset = offset, + days = sortedWeekDays, + ) + } } @Composable diff --git a/ui-component/impl/src/test/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressGridBuilderTest.kt b/ui-component/impl/src/test/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressGridBuilderTest.kt new file mode 100644 index 00000000..37e27c14 --- /dev/null +++ b/ui-component/impl/src/test/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressGridBuilderTest.kt @@ -0,0 +1,57 @@ +package br.com.sailboat.uicomponent.impl.progress + +import br.com.sailboat.todozy.domain.model.TaskProgressDay +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate + +internal class TaskProgressGridBuilderTest { + @Test + fun `should not add extra cells for seven day range starting midweek`() { + val startDate = LocalDate.of(2024, 3, 6) // Wednesday + val days = createProgressDays(startDate, 7) + + val result = buildWeekColumns(days, DefaultTaskProgressDayOrder) + + assertEquals(2, result.size) + assertEquals(2, result.first().offset) + assertEquals(7, result.sumOf { it.days.size }) + assertEquals( + days.map { it.date }, + result.flatMap { it.days }.map { it.date }, + ) + } + + @Test + fun `should keep exact day count for thirty day range`() { + val startDate = LocalDate.of(2024, 2, 4) // Sunday + val days = createProgressDays(startDate, 30) + + val result = buildWeekColumns(days, DefaultTaskProgressDayOrder) + + assertEquals(30, result.sumOf { it.days.size }) + assertEquals(6, result.first().offset) + assertEquals( + days.first().date, + result.first().days.first().date, + ) + assertEquals( + days.last().date, + result.last().days.last().date, + ) + } + + private fun createProgressDays( + start: LocalDate, + count: Int, + ): List = + (0 until count).map { offset -> + val date = start.plusDays(offset.toLong()) + TaskProgressDay( + date = date, + doneCount = 1, + notDoneCount = 0, + totalCount = 1, + ) + } +} From 3ba7bc2e67e7bc90e890332b96051e4a94795142 Mon Sep 17 00:00:00 2001 From: Brayan Bedritchuk Date: Mon, 1 Dec 2025 15:54:55 -0300 Subject: [PATCH 2/3] Remove non filters days in the heatmap and add inline task metrics --- .../impl/presentation/TaskListFragment.kt | 12 + .../viewmodel/TaskListViewIntent.kt | 1 + .../viewmodel/TaskListViewModel.kt | 289 +++++++++++++++--- .../src/main/res/layout/frg_task_list.xml | 1 - .../viewmodel/TaskListViewModelTest.kt | 101 ++++++ .../impl/progress/TaskProgressComponents.kt | 2 +- .../impl/viewholder/TaskViewHolder.kt | 66 ++++ .../impl/src/main/res/layout/task.xml | 38 ++- .../src/main/res/values-pt-rBR/strings.xml | 1 + .../impl/src/main/res/values/dimens.xml | 3 + .../impl/src/main/res/values/strings.xml | 1 + ui-component/public/build.gradle.kts | 4 + .../sailboat/uicomponent/model/TaskUiModel.kt | 5 + 13 files changed, 474 insertions(+), 50 deletions(-) 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 809bbfff..aac87ced 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 @@ -270,6 +270,18 @@ internal class TaskListFragment : Fragment(), SearchMenu by SearchMenuImpl() { override fun onClickTask(taskId: Long) { viewModel.dispatchViewIntent(TaskListViewIntent.OnClickTask(taskId = taskId)) } + + override fun onClickUndo( + taskId: Long, + status: TaskStatus, + ) { + viewModel.dispatchViewIntent( + TaskListViewIntent.OnClickUndoTask( + taskId = taskId, + status = status, + ), + ) + } }, ).apply { taskListAdapter = this } 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 802d673c..b3212bb9 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 @@ -12,5 +12,6 @@ internal sealed class TaskListViewIntent { data class OnClickTask(val taskId: Long) : TaskListViewIntent() data class OnSubmitSearchTerm(val term: String) : TaskListViewIntent() data class OnSwipeTask(val position: Int, val status: TaskStatus) : TaskListViewIntent() + data class OnClickUndoTask(val taskId: Long, val status: TaskStatus) : TaskListViewIntent() data class OnSelectProgressRange(val range: TaskProgressRange) : 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 56310185..9051aac2 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 @@ -22,6 +22,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.OnClickMenuSettings import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewIntent.OnClickNewTask import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewIntent.OnClickTask +import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewIntent.OnClickUndoTask import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewIntent.OnSelectProgressRange import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewIntent.OnStart import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewIntent.OnSubmitSearchTerm @@ -34,14 +35,13 @@ import br.com.sailboat.todozy.utility.kotlin.extension.toEndOfDayCalendar 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 kotlinx.coroutines.CoroutineScope +import br.com.sailboat.uicomponent.model.UiModel import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import java.time.LocalDate @@ -62,7 +62,10 @@ internal class TaskListViewModel( private var taskFilter = TaskFilter(category = TaskCategory.TODAY) private var selectedProgressRange = TaskProgressRange.LAST_YEAR private var currentTasks: List = emptyList() - private val swipeTaskAsyncJobs: MutableList = mutableListOf() + 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() @@ -75,6 +78,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 OnSwipeTask -> onSwipeTask(viewIntent.position, viewIntent.status) is OnSelectProgressRange -> onSelectProgressRange(viewIntent.range) } @@ -219,23 +223,14 @@ internal class TaskListViewModel( } }.awaitAll().flatten() + val itemsWithFeedback = applyInlineFeedbacks(tasks) currentTasks = domainTasks - viewState.itemsView.postValue(tasks.toMutableList()) + viewState.itemsView.postValue(itemsWithFeedback.toMutableList()) } private suspend fun loadTaskMetrics() { - if (currentTasks.isEmpty()) { - viewState.taskMetrics.postValue(null) - return - } - runCatching { coroutineScope { - val globalMetricsDeferred = - async(dispatcherProvider.default()) { - val filter = historyFilterForRange() - getTaskMetricsUseCase(filter).getOrThrow() - } val taskIds = if (currentTasks.isNotEmpty()) { currentTasks.map { it.id } @@ -244,23 +239,40 @@ internal class TaskListViewModel( ?.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 perTaskConsecutiveDeferred = async(dispatcherProvider.default()) { if (taskIds.isEmpty()) { - emptyList() + emptyMap() } else { - taskIds.map { taskId -> - async { - val filter = historyFilterForRange(taskId, includeText = true) - getTaskMetricsUseCase(filter).getOrThrow().consecutiveDone + val deferredMap = + taskIds.associateWith { taskId -> + async { + val filter = historyFilterForRange(taskId, includeText = true) + getTaskMetricsUseCase(filter).getOrThrow().consecutiveDone + } } - }.awaitAll() + deferredMap.mapValues { it.value.await() } } } val globalMetrics = globalMetricsDeferred.await() val perTaskConsecutive = perTaskConsecutiveDeferred.await() - val totalConsecutive = perTaskConsecutive.sum() + val totalConsecutive = perTaskConsecutive.values.sum() + + baseTaskConsecutive.clear() + baseTaskConsecutive.putAll(perTaskConsecutive) TaskMetrics( doneTasks = globalMetrics.doneTasks, @@ -269,9 +281,17 @@ internal class TaskListViewModel( ) } }.onSuccess { metrics -> - viewState.taskMetrics.postValue(metrics) + if (metrics == null) { + baseTaskMetrics = null + viewState.taskMetrics.postValue(null) + } else { + baseTaskMetrics = metrics + publishTaskMetricsWithPending() + } }.onFailure { throwable -> logService.error(throwable) + baseTaskMetrics = null + baseTaskConsecutive.clear() viewState.taskMetrics.postValue(null) } } @@ -300,45 +320,215 @@ internal class TaskListViewModel( ) } - private fun onSwipeTask( - position: Int, + private fun onClickUndoTask( + taskId: Long, status: TaskStatus, - ) = launchSwipeTask { + ) = viewModelScope.launch { try { - viewState.taskMetrics.value = null + cancelInlineFeedback(taskId) + inlineTaskFeedbacks.remove(taskId) + publishItemsWithInlineFeedback() + publishTaskMetricsWithPending() + } catch (e: Exception) { + logService.error(e) + viewState.viewAction.value = TaskListViewAction.ShowErrorCompletingTask + } + } - val itemsView = viewState.itemsView.value + private suspend fun loadInlineMetrics(taskId: Long): TaskMetrics? = + runCatching { + withContext(dispatcherProvider.default()) { + val filter = historyFilterForRange(taskId, includeText = true) + getTaskMetricsUseCase(filter).getOrThrow() + } + }.onFailure { throwable -> + logService.error(throwable) + }.getOrNull() - val taskId = (itemsView?.get(position) as TaskUiModel).taskId + private fun publishItemsWithInlineFeedback(baseItems: List? = viewState.itemsView.value) { + val updatedItems = applyInlineFeedbacks(baseItems.orEmpty()) + viewState.itemsView.postValue(updatedItems.toMutableList()) + } - completeTaskUseCase(taskId, status) - loadTaskMetrics() + private fun applyInlineFeedbacks(items: List): List { + if (inlineTaskFeedbacks.isEmpty()) { + return items.map { item -> + if (item is TaskUiModel) { + item.copy( + showInlineMetrics = false, + inlineMetrics = null, + inlineStatus = null, + ) + } else { + item + } + } + } - itemsView.removeAt(position) - viewState.viewAction.postValue(TaskListViewAction.UpdateRemovedTask(position)) + val itemsWithFeedback = items.toMutableList() - viewState.itemsView.postValue(itemsView) + inlineTaskFeedbacks.values.forEach { feedback -> + val predictedMetrics = predictedTaskMetrics(feedback) + val existingIndex = + itemsWithFeedback.indexOfFirst { (it as? TaskUiModel)?.taskId == feedback.uiModel.taskId } - delay(TASK_SWIPE_DELAY_IN_MILLIS) + val baseTaskUiModel = + (itemsWithFeedback.getOrNull(existingIndex) as? TaskUiModel) ?: feedback.uiModel - if (swipeTaskAsyncJobs.size == 1) { - loadTasks() - loadTaskMetrics() - updateTodayProgress(status) + val inlineTaskUiModel = + baseTaskUiModel.copy( + showInlineMetrics = true, + inlineMetrics = predictedMetrics, + inlineStatus = feedback.status, + ) + + if (existingIndex >= 0) { + itemsWithFeedback[existingIndex] = inlineTaskUiModel } - } catch (e: Exception) { - logService.error(e) - viewState.viewAction.value = TaskListViewAction.ShowErrorCompletingTask } + + return itemsWithFeedback } - private fun launchSwipeTask(block: suspend CoroutineScope.() -> Unit) { - val job: Job = + private fun publishTaskMetricsWithPending() { + val pending = inlineTaskFeedbacks.values + if (pending.isEmpty() && baseTaskMetrics == null) { + viewState.taskMetrics.postValue(null) + return + } + + var doneTasks = baseTaskMetrics?.doneTasks ?: 0 + var notDoneTasks = baseTaskMetrics?.notDoneTasks ?: 0 + var consecutiveDone = baseTaskMetrics?.consecutiveDone ?: 0 + + pending.forEach { feedback -> + when (feedback.status) { + TaskStatus.DONE -> { + doneTasks += 1 + consecutiveDone += 1 + } + TaskStatus.NOT_DONE -> { + notDoneTasks += 1 + val baseConsecutive = baseTaskConsecutive[feedback.uiModel.taskId] ?: 0 + consecutiveDone -= baseConsecutive + } + } + } + + viewState.taskMetrics.postValue( + TaskMetrics( + doneTasks = doneTasks, + notDoneTasks = notDoneTasks, + consecutiveDone = consecutiveDone, + ), + ) + } + + private fun scheduleInlineFeedbackCommit(taskId: Long) { + inlineTaskJobs[taskId]?.cancel() + + val job = viewModelScope.launch { - supervisorScope { block() } + delay(TASK_SWIPE_DELAY_IN_MILLIS) + commitPendingSwipe(taskId) } - swipeTaskAsyncJobs.add(job) - job.invokeOnCompletion { swipeTaskAsyncJobs.remove(job) } + + inlineTaskJobs[taskId] = job + job.invokeOnCompletion { inlineTaskJobs.remove(taskId) } + } + + private suspend fun commitPendingSwipe(taskId: Long) { + val feedback = inlineTaskFeedbacks[taskId] ?: return + + runCatching { + completeTaskUseCase(feedback.uiModel.taskId, feedback.status) + inlineTaskFeedbacks.remove(taskId) + removeTaskFromList(feedback.uiModel.taskId) + loadTasks() + updateTodayProgress(feedback.status) + loadTaskMetrics() + publishTaskMetricsWithPending() + }.onFailure { throwable -> + logService.error(throwable) + viewState.viewAction.postValue(TaskListViewAction.ShowErrorCompletingTask) + publishItemsWithInlineFeedback() + publishTaskMetricsWithPending() + } + } + + private fun predictedTaskMetrics(feedback: InlineTaskFeedback): TaskMetrics { + val baseConsecutive = baseTaskConsecutive[feedback.uiModel.taskId] ?: 0 + val baseMetrics = + feedback.metrics + ?: TaskMetrics( + doneTasks = 0, + notDoneTasks = 0, + consecutiveDone = baseConsecutive, + ) + + return when (feedback.status) { + TaskStatus.DONE -> + baseMetrics.copy( + doneTasks = baseMetrics.doneTasks + 1, + consecutiveDone = baseMetrics.consecutiveDone + 1, + ) + + TaskStatus.NOT_DONE -> + baseMetrics.copy( + notDoneTasks = baseMetrics.notDoneTasks + 1, + consecutiveDone = 0, + ) + } + } + + private fun removeTaskFromList(taskId: Long) { + val itemsView = viewState.itemsView.value?.toMutableList() ?: mutableListOf() + val position = itemsView.indexOfFirst { (it as? TaskUiModel)?.taskId == taskId } + + if (position >= 0) { + itemsView.removeAt(position) + viewState.viewAction.postValue(TaskListViewAction.UpdateRemovedTask(position)) + } + + currentTasks = currentTasks.filterNot { it.id == taskId } + publishItemsWithInlineFeedback(itemsView) + } + + private fun cancelInlineFeedback(taskId: Long) { + inlineTaskJobs[taskId]?.cancel() + inlineTaskJobs.remove(taskId) + inlineTaskFeedbacks.remove(taskId) + } + + private fun onSwipeTask( + position: Int, + status: TaskStatus, + ) { + viewModelScope.launch { + try { + val itemsView = viewState.itemsView.value ?: return@launch + val taskUiModel = itemsView.getOrNull(position) as? TaskUiModel ?: return@launch + + cancelInlineFeedback(taskUiModel.taskId) + + val inlineMetrics = loadInlineMetrics(taskUiModel.taskId) + + inlineTaskFeedbacks[taskUiModel.taskId] = + InlineTaskFeedback( + uiModel = taskUiModel, + metrics = inlineMetrics, + status = status, + position = position, + ) + + publishItemsWithInlineFeedback(itemsView) + publishTaskMetricsWithPending() + scheduleInlineFeedbackCommit(taskUiModel.taskId) + } catch (e: Exception) { + logService.error(e) + viewState.viewAction.value = TaskListViewAction.ShowErrorCompletingTask + } + } } private fun updateTodayProgress(status: TaskStatus) { @@ -427,3 +617,10 @@ private data class ProgressCacheKey( val range: TaskProgressRange, val searchTerm: String, ) + +private data class InlineTaskFeedback( + val uiModel: TaskUiModel, + val metrics: TaskMetrics?, + val status: TaskStatus, + val position: Int, +) diff --git a/feature/task-list/impl/src/main/res/layout/frg_task_list.xml b/feature/task-list/impl/src/main/res/layout/frg_task_list.xml index ebfed927..b5f3ec90 100644 --- a/feature/task-list/impl/src/main/res/layout/frg_task_list.xml +++ b/feature/task-list/impl/src/main/res/layout/frg_task_list.xml @@ -38,7 +38,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingStart="@dimen/spacing_medium" - android:paddingTop="@dimen/spacing_small" android:paddingEnd="@dimen/spacing_medium" android:paddingBottom="@dimen/spacing_small" android:background="?attr/colorPrimary" 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 439fca54..9e9243db 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 @@ -30,7 +30,9 @@ import io.mockk.every import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -39,6 +41,8 @@ import java.util.Calendar import kotlin.test.assertEquals import kotlin.test.assertTrue +private const val TASK_SWIPE_DELAY_IN_MILLIS = 4000L + @ExperimentalCoroutinesApi internal class TaskListViewModelTest { @get:Rule @@ -233,6 +237,7 @@ internal class TaskListViewModelTest { prepareScenario() viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(position, status)) + advanceTimeBy(TASK_SWIPE_DELAY_IN_MILLIS) advanceUntilIdle() coVerify(exactly = 1) { completeTaskUseCase(taskId = 978L, status = status) } @@ -258,10 +263,16 @@ internal class TaskListViewModelTest { prepareScenario() viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(position, status)) + advanceTimeBy(TASK_SWIPE_DELAY_IN_MILLIS) advanceUntilIdle() val expected = TaskListViewAction.UpdateRemovedTask(position) assertTrue { list.contains(expected) } + assertTrue { + viewModel.viewState.itemsView.value + ?.filterIsInstance() + ?.none { it.taskId == task2.taskId } == true + } } } @@ -279,6 +290,7 @@ internal class TaskListViewModelTest { prepareScenario() viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(position, status)) + advanceTimeBy(TASK_SWIPE_DELAY_IN_MILLIS) advanceUntilIdle() coVerify { @@ -292,6 +304,41 @@ internal class TaskListViewModelTest { } } + @Test + fun `should show inline metrics when dispatchViewAction is called with OnSwipeTask`() = + runTest(coroutinesTestRule.dispatcher) { + val taskId = 978L + val tasks = + mutableListOf( + TaskUiModel(taskId = 543L, taskName = "Task 543"), + TaskUiModel(taskId = taskId, taskName = "Task 978"), + ) + val inlineMetrics = TaskMetrics(doneTasks = 2, notDoneTasks = 1, consecutiveDone = 3) + prepareScenario( + tasksView = tasks, + tasksResult = + Result.success( + listOf( + Task(id = 543L, name = "Task 543", notes = null), + Task(id = taskId, name = "Task 978", notes = null), + ), + ), + ) + coEvery { + getTaskMetricsUseCase(match { it.taskId == taskId }) + } returns Result.success(inlineMetrics) + + viewModel.viewState.itemsView.value = tasks + + viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(1, TaskStatus.DONE)) + runCurrent() + + val updatedTask = viewModel.viewState.itemsView.value?.get(1) as TaskUiModel + assertTrue(updatedTask.showInlineMetrics) + assertEquals(TaskMetrics(doneTasks = 3, notDoneTasks = 1, consecutiveDone = 4), updatedTask.inlineMetrics) + assertEquals(TaskStatus.DONE, updatedTask.inlineStatus) + } + @Test fun `should call getTaskMetricsUseCase when dispatchViewAction is called with OnSwipeTask on a task with repetitive alarm`() { runTest(coroutinesTestRule.dispatcher) { @@ -328,11 +375,65 @@ internal class TaskListViewModelTest { } } + @Test + fun `should show optimistic metrics while commit is pending`() = + runTest(coroutinesTestRule.dispatcher) { + val tasks = + mutableListOf( + TaskUiModel(taskId = 543L, taskName = "Task 543"), + ) + prepareScenario(tasksView = tasks) + + viewModel.dispatchViewIntent(TaskListViewIntent.OnStart) + advanceUntilIdle() + + viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(position = 0, status = TaskStatus.DONE)) + runCurrent() + + assertEquals(TaskMetrics(doneTasks = 1, notDoneTasks = 0, consecutiveDone = 1), viewModel.viewState.taskMetrics.value) + } + + @Test + fun `should undo swipe and restore task`() = + runTest(coroutinesTestRule.dispatcher) { + val taskId = 978L + val tasks = + mutableListOf( + TaskUiModel(taskId = taskId, taskName = "Task 978"), + ) + prepareScenario( + tasksView = tasks, + tasksResult = + Result.success( + listOf( + Task(id = taskId, name = "Task 978", notes = null), + ), + ), + ) + + viewModel.viewState.itemsView.value = tasks + + viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(0, TaskStatus.DONE)) + runCurrent() + + viewModel.dispatchViewIntent(TaskListViewIntent.OnClickUndoTask(taskId, TaskStatus.DONE)) + advanceTimeBy(TASK_SWIPE_DELAY_IN_MILLIS) + advanceUntilIdle() + + coVerify(exactly = 0) { completeTaskUseCase(taskId, any()) } + val restoredTask = + viewModel.viewState.itemsView.value?.filterIsInstance()?.firstOrNull() + assertTrue(restoredTask?.showInlineMetrics == false) + } + @Test fun `should reload progress when progress range changes`() = runTest(coroutinesTestRule.dispatcher) { prepareScenario() + viewModel.dispatchViewIntent(TaskListViewIntent.OnStart) + advanceUntilIdle() + viewModel.dispatchViewIntent(TaskListViewIntent.OnSelectProgressRange(TaskProgressRange.LAST_30_DAYS)) advanceUntilIdle() diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt index 9461ea18..d73aa3d8 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt @@ -210,7 +210,7 @@ private fun TaskProgressGrid( style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), modifier = Modifier.padding(top = 4.dp), - ) + ) return } diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt index 2490f57e..b0750876 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt @@ -2,6 +2,8 @@ package br.com.sailboat.uicomponent.impl.viewholder import android.util.Log import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import br.com.sailboat.todozy.domain.model.TaskStatus import br.com.sailboat.todozy.utility.android.calendar.formatTimeWithAndroidFormat import br.com.sailboat.todozy.utility.android.calendar.getMonthAndDayShort import br.com.sailboat.todozy.utility.android.calendar.toShortDateView @@ -12,6 +14,7 @@ import br.com.sailboat.todozy.utility.android.view.visible import br.com.sailboat.todozy.utility.kotlin.extension.isAfterTomorrow import br.com.sailboat.todozy.utility.kotlin.extension.isBeforeToday import br.com.sailboat.todozy.utility.kotlin.extension.isCurrentYear +import br.com.sailboat.uicomponent.impl.R import br.com.sailboat.uicomponent.impl.databinding.VhTaskBinding import br.com.sailboat.uicomponent.model.TaskUiModel import java.util.Calendar @@ -22,8 +25,19 @@ class TaskViewHolder(parent: ViewGroup, private val callback: Callback) : ) { interface Callback { fun onClickTask(taskId: Long) + fun onClickUndo( + taskId: Long, + status: TaskStatus, + ) } + private val defaultElevation = binding.root.cardElevation + private val inlineElevation = binding.root.resources.getDimension(R.dimen.task_inline_metrics_elevation) + private val defaultMinHeight = binding.task.root.minimumHeight + private val defaultBottomPadding = binding.task.root.paddingBottom + private val defaultInlineTopMargin = + (binding.task.inlineMetricsContainer.layoutParams as ConstraintLayout.LayoutParams).topMargin + override fun bind(item: TaskUiModel) = with(binding) { task.tvTaskName.text = item.taskName @@ -31,6 +45,7 @@ class TaskViewHolder(parent: ViewGroup, private val callback: Callback) : root.setSafeClickListener { callback.onClickTask(item.taskId) } + bindInlineMetrics(item) } private fun bindTaskAlarm(item: TaskUiModel) { @@ -80,4 +95,55 @@ class TaskViewHolder(parent: ViewGroup, private val callback: Callback) : task.tvTaskDate.gone() } } + + private fun bindInlineMetrics(item: TaskUiModel) = + with(binding) { + if (item.showInlineMetrics) { + root.cardElevation = inlineElevation + task.root.minimumHeight = 0 + task.root.setPadding(task.root.paddingLeft, task.root.paddingTop, task.root.paddingRight, 0) + updateInlineTopMargin(0) + task.inlineMetricsContainer.visible() + bindInlineMetricsValues(item) + task.tvTaskName.gone() + task.flTaskDateTime.gone() + task.btnInlineUndo.setSafeClickListener { + callback.onClickUndo(item.taskId, item.inlineStatus ?: TaskStatus.NOT_DONE) + } + } else { + root.cardElevation = defaultElevation + task.root.minimumHeight = defaultMinHeight + task.root.setPadding( + task.root.paddingLeft, + task.root.paddingTop, + task.root.paddingRight, + defaultBottomPadding, + ) + updateInlineTopMargin(defaultInlineTopMargin) + task.inlineMetricsContainer.gone() + task.tvTaskName.visible() + task.flTaskDateTime.visible() + task.btnInlineUndo.setOnClickListener(null) + } + } + + private fun updateInlineTopMargin(margin: Int) { + val layoutParams = + binding.task.inlineMetricsContainer.layoutParams as ConstraintLayout.LayoutParams + layoutParams.topMargin = margin + binding.task.inlineMetricsContainer.layoutParams = layoutParams + } + + private fun bindInlineMetricsValues(item: TaskUiModel) = + with(binding.task.inlineTaskMetrics) { + tvMetricsFire.text = item.inlineMetrics?.consecutiveDone?.toString().orEmpty() + tvMetricsDone.text = item.inlineMetrics?.doneTasks?.toString().orEmpty() + tvMetricsNotDone.text = item.inlineMetrics?.notDoneTasks?.toString().orEmpty() + + if ((item.inlineMetrics?.consecutiveDone ?: 0) == 0) { + taskMetricsLlFire.gone() + } else { + taskMetricsLlFire.visible() + } + } } diff --git a/ui-component/impl/src/main/res/layout/task.xml b/ui-component/impl/src/main/res/layout/task.xml index 86303490..6d749570 100644 --- a/ui-component/impl/src/main/res/layout/task.xml +++ b/ui-component/impl/src/main/res/layout/task.xml @@ -3,7 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent" + android:layout_height="wrap_content" android:minHeight="64dp" android:paddingBottom="@dimen/spacing_medium"> @@ -58,4 +58,38 @@ tools:text="15:00" /> - \ No newline at end of file + + + + + + + + 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 4ce3baf3..feb34e97 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 @@ -138,6 +138,7 @@ Status Data/Hora Restaurar + DESFAZER Nome Quantidade Insira o nome do produto diff --git a/ui-component/impl/src/main/res/values/dimens.xml b/ui-component/impl/src/main/res/values/dimens.xml index b2cce462..d3c5541e 100644 --- a/ui-component/impl/src/main/res/values/dimens.xml +++ b/ui-component/impl/src/main/res/values/dimens.xml @@ -24,4 +24,7 @@ 32dp 40dp 48dp + + + 6dp diff --git a/ui-component/impl/src/main/res/values/strings.xml b/ui-component/impl/src/main/res/values/strings.xml index dcd30763..8aa74d2d 100644 --- a/ui-component/impl/src/main/res/values/strings.xml +++ b/ui-component/impl/src/main/res/values/strings.xml @@ -141,6 +141,7 @@ Status Date/Time Restore + UNDO Name Quantity Enter the product name diff --git a/ui-component/public/build.gradle.kts b/ui-component/public/build.gradle.kts index c2a357f6..15fe4855 100644 --- a/ui-component/public/build.gradle.kts +++ b/ui-component/public/build.gradle.kts @@ -11,3 +11,7 @@ java { tasks.withType { kotlinOptions.jvmTarget = "17" } + +dependencies { + api(project(Module.domain)) +} diff --git a/ui-component/public/src/main/java/br/com/sailboat/uicomponent/model/TaskUiModel.kt b/ui-component/public/src/main/java/br/com/sailboat/uicomponent/model/TaskUiModel.kt index af1f56e1..93f84567 100644 --- a/ui-component/public/src/main/java/br/com/sailboat/uicomponent/model/TaskUiModel.kt +++ b/ui-component/public/src/main/java/br/com/sailboat/uicomponent/model/TaskUiModel.kt @@ -1,5 +1,7 @@ package br.com.sailboat.uicomponent.model +import br.com.sailboat.todozy.domain.model.TaskMetrics +import br.com.sailboat.todozy.domain.model.TaskStatus import java.util.Calendar data class TaskUiModel( @@ -7,5 +9,8 @@ data class TaskUiModel( val taskName: String, val alarm: Calendar? = null, val alarmColor: Int? = null, + val showInlineMetrics: Boolean = false, + val inlineMetrics: TaskMetrics? = null, + val inlineStatus: TaskStatus? = null, override val uiModelId: Int = UiModelType.TASK.ordinal, ) : UiModel From f6ad05080150d5e6a62da80bf2b598b7a34778b1 Mon Sep 17 00:00:00 2001 From: Brayan Bedritchuk Date: Mon, 1 Dec 2025 16:01:57 -0300 Subject: [PATCH 3/3] Fix app sign --- app/build.gradle.kts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b6eaab1..861146b4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,13 +15,19 @@ val localProps = } } +val hasSigningProps = + listOf("STORE_FILE", "STORE_PASSWORD", "KEY_ALIAS", "KEY_PASSWORD") + .all { key -> localProps.getProperty(key).isNullOrBlank().not() } + android { signingConfigs { - create("config") { - keyAlias = localProps.getProperty("KEY_ALIAS") - keyPassword = localProps.getProperty("KEY_PASSWORD") - storeFile = file(localProps.getProperty("STORE_FILE")) - storePassword = localProps.getProperty("STORE_PASSWORD") + if (hasSigningProps) { + create("config") { + keyAlias = localProps.getProperty("KEY_ALIAS") + keyPassword = localProps.getProperty("KEY_PASSWORD") + storeFile = file(localProps.getProperty("STORE_FILE")) + storePassword = localProps.getProperty("STORE_PASSWORD") + } } } @@ -39,7 +45,12 @@ android { } buildTypes { getByName("release") { - signingConfig = signingConfigs.getByName("config") + signingConfig = + if (hasSigningProps) { + signingConfigs.getByName("config") + } else { + signingConfigs.getByName("debug") + } isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -49,7 +60,12 @@ android { } getByName("debug") { - signingConfig = signingConfigs.getByName("config") + signingConfig = + if (hasSigningProps) { + signingConfigs.getByName("config") + } else { + signingConfigs.getByName("debug") + } isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"),