From ac0e4663c056013e6c1930516d7d7341032b9777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Francisco=20Concepci=C3=B3n?= Date: Wed, 16 Oct 2024 18:32:00 +0100 Subject: [PATCH 1/4] Track when postpone is shown via notification --- .../atomtasks/postponetask/ui/PostponeTaskViewModel.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModel.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModel.kt index 2eb2138c..1ba87471 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModel.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModel.kt @@ -1,13 +1,16 @@ package com.costular.atomtasks.postponetask.ui import androidx.lifecycle.viewModelScope +import com.costular.atomtasks.analytics.AtomAnalytics import com.costular.atomtasks.core.ui.mvi.MviViewModel import com.costular.atomtasks.postponetask.domain.GetPostponeChoiceListUseCase import com.costular.atomtasks.postponetask.domain.PostponeChoice import com.costular.atomtasks.tasks.usecase.PostponeTaskUseCase import com.costular.atomtasks.notifications.TaskNotificationManager import com.costular.atomtasks.postponetask.domain.PostponeChoiceCalculator +import com.costular.atomtasks.tasks.analytics.NotificationsActionsPostpone import dagger.hilt.android.lifecycle.HiltViewModel +import hilt_aggregated_deps._com_costular_atomtasks_postponetask_ui_PostponeTaskActivity_GeneratedInjector import javax.inject.Inject import kotlinx.coroutines.launch @@ -17,9 +20,11 @@ class PostponeTaskViewModel @Inject constructor( private val postponeTaskUseCase: PostponeTaskUseCase, private val taskNotificationManager: TaskNotificationManager, private val postponeChoiceCalculator: PostponeChoiceCalculator, + private val analytics: AtomAnalytics, ) : MviViewModel(PostponeTaskScreenUiState()) { fun initialize(taskId: Long) { + analytics.track(NotificationsActionsPostpone) setTaskId(taskId) loadPostponeChoices() } From 0fe418672a5bacaca71da6dd29051f53a3991490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Francisco=20Concepci=C3=B3n?= Date: Wed, 16 Oct 2024 18:32:00 +0100 Subject: [PATCH 2/4] Improve copies --- core/ui/src/main/res/values-es/strings.xml | 6 ++++-- core/ui/src/main/res/values/strings.xml | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/core/ui/src/main/res/values-es/strings.xml b/core/ui/src/main/res/values-es/strings.xml index 3f235765..a6a84b66 100644 --- a/core/ui/src/main/res/values-es/strings.xml +++ b/core/ui/src/main/res/values-es/strings.xml @@ -52,7 +52,7 @@ Posponer Marcar como hecho Marcar como no hecho - 15 minutos + 30 minutos 1 hora Esta noche Mañana por la mañana @@ -81,7 +81,7 @@ Para utilizar la función de recordatorios, necesitamos el permiso para alarmas exactas. Sin él, los recordatorios no funcionarán Lo entiendo Ir a Ajustes - " ¿No es el momento? Decide cuándo" + Posponer tarea El permiso de notificaciones es necesario "Para utilizar la función de recordatorios, necesitamos el permiso de notificaciones. Sin él, los recordatorios no funcionarán " No gracias @@ -99,4 +99,6 @@ Esta tarea Esta tarea y las posteriores Título de la tarea + 3 horas + Escoge fecha y hora diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 9a04db77..fe349a32 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -73,13 +73,15 @@ Postpone - Not now? Pick when - 15 minutes - 1 hour + Reschedule task + In 30 minutes + In 1 hour + In 3 hours Tonight Tomorrow morning Next weekend Next week + Pick a date & time Settings From 5703b18532e2516622fc008bd8ed8bd70cb87a9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Francisco=20Concepci=C3=B3n?= Date: Wed, 16 Oct 2024 18:32:00 +0100 Subject: [PATCH 3/4] Add new postpone screen A new mode with custom date & time has been added along with tracking to know how the users use this feature --- .../atomtasks/core/util/DateTimeFormatters.kt | 1 + core/ui/src/main/res/values-es/strings.xml | 2 + core/ui/src/main/res/values/strings.xml | 2 + .../domain/GetPostponeChoiceListUseCase.kt | 34 ++- .../postponetask/domain/PostponeChoice.kt | 18 +- .../domain/PostponeChoiceCalculator.kt | 28 +- .../postponetask/domain/PostponeChoiceType.kt | 20 ++ .../postponetask/ui/PostponeAnalytics.kt | 29 +++ .../postponetask/ui/PostponeTaskActivity.kt | 6 - .../postponetask/ui/PostponeTaskScreen.kt | 239 +++++++++++++++--- .../ui/PostponeTaskScreenPreviewData.kt | 31 ++- .../ui/PostponeTaskScreenUiState.kt | 7 + .../postponetask/ui/PostponeTaskUiEvents.kt | 2 - .../postponetask/ui/PostponeTaskViewModel.kt | 145 +++++++++-- ...efaultPostponeChoiceTypeCalculatorTest.kt} | 46 ++-- .../GetPostponeChoiceListUseCaseTest.kt | 50 ++++ .../ui/PostponeTaskViewModelTest.kt | 145 +++++++++-- 17 files changed, 663 insertions(+), 142 deletions(-) create mode 100644 feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoiceType.kt create mode 100644 feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeAnalytics.kt rename feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/{DefaultPostponeChoiceCalculatorTest.kt => DefaultPostponeChoiceTypeCalculatorTest.kt} (67%) create mode 100644 feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCaseTest.kt diff --git a/core/src/main/java/com/costular/atomtasks/core/util/DateTimeFormatters.kt b/core/src/main/java/com/costular/atomtasks/core/util/DateTimeFormatters.kt index be1cc959..2563200e 100644 --- a/core/src/main/java/com/costular/atomtasks/core/util/DateTimeFormatters.kt +++ b/core/src/main/java/com/costular/atomtasks/core/util/DateTimeFormatters.kt @@ -9,6 +9,7 @@ object DateTimeFormatters { val shortDayOfWeekFormatter = DateTimeFormatter.ofPattern("E") val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) val monthFormatter = DateTimeFormatter.ofPattern("MMMM yyyy") + val dateWithMonthFormatter = DateTimeFormatter.ofPattern("d MMM") fun LocalTime.formatTime(locale: Locale): String = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(this) diff --git a/core/ui/src/main/res/values-es/strings.xml b/core/ui/src/main/res/values-es/strings.xml index a6a84b66..d5ca2c11 100644 --- a/core/ui/src/main/res/values-es/strings.xml +++ b/core/ui/src/main/res/values-es/strings.xml @@ -101,4 +101,6 @@ Título de la tarea 3 horas Escoge fecha y hora + Día + Hora diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index fe349a32..4ddd08da 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -82,6 +82,8 @@ Next weekend Next week Pick a date & time + Date + Time Settings diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCase.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCase.kt index fa034857..5fcda3f6 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCase.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCase.kt @@ -1,14 +1,32 @@ package com.costular.atomtasks.postponetask.domain import com.costular.atomtasks.core.usecase.UseCase +import java.time.LocalDate +import java.time.LocalDateTime import javax.inject.Inject -class GetPostponeChoiceListUseCase @Inject constructor(): UseCase> { - override suspend fun invoke(params: Unit): List = listOf( - PostponeChoice.FifteenMinutes, - PostponeChoice.OneHour, - PostponeChoice.Tonight, - PostponeChoice.TomorrowMorning, - PostponeChoice.NextWeek, - ) +class GetPostponeChoiceListUseCase @Inject constructor( + private val postponeChoiceCalculator: PostponeChoiceCalculator, +) : UseCase> { + + override suspend fun invoke(params: Unit): List { + return Choices.map { PostponeChoice(it, postponeChoiceCalculator.calculatePostpone(it)) } + .filter { + it.postponeChoiceType != PostponeChoiceType.Tonight || + (it.postponeChoiceType == PostponeChoiceType.Tonight && + it.postponeDateTime?.toLocalDate() == LocalDate.now()) + } + } + + companion object { + internal val Choices = listOf( + PostponeChoiceType.ThirtyMinutes, + PostponeChoiceType.OneHour, + PostponeChoiceType.ThreeHours, + PostponeChoiceType.Tonight, + PostponeChoiceType.TomorrowMorning, + PostponeChoiceType.NextWeek, + PostponeChoiceType.Custom, + ) + } } diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoice.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoice.kt index 755847e4..0e32c735 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoice.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoice.kt @@ -1,16 +1,8 @@ package com.costular.atomtasks.postponetask.domain -sealed interface PostponeChoice { +import java.time.LocalDateTime - data object FifteenMinutes : PostponeChoice - - data object OneHour : PostponeChoice - - data object Tonight : PostponeChoice - - data object TomorrowMorning : PostponeChoice - - data object NextWeekend : PostponeChoice - - data object NextWeek : PostponeChoice -} +data class PostponeChoice( + val postponeChoiceType: PostponeChoiceType, + val postponeDateTime: LocalDateTime?, +) \ No newline at end of file diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoiceCalculator.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoiceCalculator.kt index 88818b50..e02d08bb 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoiceCalculator.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoiceCalculator.kt @@ -8,24 +8,28 @@ import java.time.LocalDate import java.time.LocalDateTime interface PostponeChoiceCalculator { - fun calculatePostpone(postponeChoice: PostponeChoice): LocalDateTime + fun calculatePostpone(postponeChoiceType: PostponeChoiceType): LocalDateTime? } @Suppress("MagicNumber") class DefaultPostponeChoiceCalculator( private val clock: Clock, ) : PostponeChoiceCalculator { - override fun calculatePostpone(postponeChoice: PostponeChoice): LocalDateTime { - return when (postponeChoice) { - PostponeChoice.FifteenMinutes -> { - now().plusMinutes(15) + override fun calculatePostpone(postponeChoiceType: PostponeChoiceType): LocalDateTime? { + return when (postponeChoiceType) { + PostponeChoiceType.ThirtyMinutes -> { + now().plusMinutes(30) } - PostponeChoice.OneHour -> { + PostponeChoiceType.OneHour -> { now().plusHours(1) } - PostponeChoice.Tonight -> { + PostponeChoiceType.ThreeHours -> { + now().plusHours(3) + } + + PostponeChoiceType.Tonight -> { val tonigth = today().atTime(PredefinedTimes.Night) if (tonigth.isBefore(LocalDateTime.now(clock))) { tonigth.plusDays(1) @@ -34,17 +38,21 @@ class DefaultPostponeChoiceCalculator( } } - PostponeChoice.TomorrowMorning -> { + PostponeChoiceType.TomorrowMorning -> { today().plusDays(1).atTime(PredefinedTimes.Morning) } - PostponeChoice.NextWeekend -> { + PostponeChoiceType.NextWeekend -> { today().findNextWeekend().atTime(PredefinedTimes.Morning) } - PostponeChoice.NextWeek -> { + PostponeChoiceType.NextWeek -> { today().findNextWeek().atTime(PredefinedTimes.Morning) } + + PostponeChoiceType.Custom -> { + null + } } } diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoiceType.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoiceType.kt new file mode 100644 index 00000000..45e2cbe7 --- /dev/null +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoiceType.kt @@ -0,0 +1,20 @@ +package com.costular.atomtasks.postponetask.domain + +sealed interface PostponeChoiceType { + + data object ThirtyMinutes : PostponeChoiceType + + data object OneHour : PostponeChoiceType + + data object ThreeHours: PostponeChoiceType + + data object Tonight : PostponeChoiceType + + data object TomorrowMorning : PostponeChoiceType + + data object NextWeekend : PostponeChoiceType + + data object NextWeek : PostponeChoiceType + + data object Custom : PostponeChoiceType +} diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeAnalytics.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeAnalytics.kt new file mode 100644 index 00000000..5588db85 --- /dev/null +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeAnalytics.kt @@ -0,0 +1,29 @@ +package com.costular.atomtasks.postponetask.ui + +import com.costular.atomtasks.analytics.TrackingEvent +import java.time.LocalDate +import java.time.LocalTime + +data class PostponeDefaultOptionClicked(val option: String) : TrackingEvent( + name = "postpone_default_clicked", + attributes = mapOf("option" to option) +) + +data object PostponeCustomOptionClicked : TrackingEvent(name = "postpone_custom_clicked") + +data object PostponeCustomDatePickerOpened : + TrackingEvent(name = "postpone_custom_date_picker_opened") + +data object PostponeCustomTimePickerOpened : + TrackingEvent(name = "postpone_custom_time_picker_opened") + +data class PostponeCustomRescheduled( + val date: LocalDate, + val time: LocalTime, +) : TrackingEvent( + name = "postpone_custom_rescheduled", + attributes = mapOf( + "date" to date.toString(), + "time" to time.toString(), + ) +) \ No newline at end of file diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskActivity.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskActivity.kt index 9cc3b0f4..01edf72a 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskActivity.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskActivity.kt @@ -28,12 +28,6 @@ class PostponeTaskActivity : ComponentActivity() { } companion object { - fun buildIntent(context: Context, taskId: Long): Intent { - return Intent(context, PostponeTaskActivity::class.java).apply { - putExtra(ParamTaskId, taskId) - } - } - private const val ParamTaskId = "postpone_param_task_id" } } diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreen.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreen.kt index 0fe5c2b9..dcf50ced 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreen.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreen.kt @@ -1,42 +1,68 @@ package com.costular.atomtasks.postponetask.ui -import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row 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.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.outlined.AccessTime +import androidx.compose.material.icons.outlined.CalendarToday +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.costular.atomtasks.core.ui.utils.DateUtils.dayAsText +import com.costular.atomtasks.core.ui.utils.ofLocalizedTime +import com.costular.atomtasks.core.util.DateTimeFormatters.dateWithMonthFormatter import com.costular.atomtasks.postponetask.domain.PostponeChoice +import com.costular.atomtasks.postponetask.domain.PostponeChoiceType import com.costular.designsystem.components.CircularLoadingIndicator +import com.costular.designsystem.components.PrimaryButton import com.costular.designsystem.dialogs.AtomSheetTitle +import com.costular.designsystem.dialogs.DatePickerDialog +import com.costular.designsystem.dialogs.TimePickerDialog import com.costular.designsystem.theme.AppTheme import com.costular.designsystem.theme.AtomTheme +import java.time.LocalDate +import java.time.LocalDateTime import com.costular.atomtasks.core.ui.R.string as S +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PostponeTaskScreen( taskId: Long, @@ -54,9 +80,28 @@ fun PostponeTaskScreen( } } + if (state.isSelectDayDialogOpen) { + DatePickerDialog( + onDismiss = viewModel::dismissCustomDate, + currentDate = requireNotNull(state.customPostponeDate), + onDateSelected = viewModel::onUpdateDate, + ) + } + + if (state.isSelectTimeDialogOpen) { + TimePickerDialog( + onDismiss = viewModel::dismissCustomTime, + selectedTime = requireNotNull(state.customPostponeTime), + onSelectTime = viewModel::onUpdateTime + ) + } + PostponeTaskScreen( state = state, onPickPostponeChoice = viewModel::onSelectPostponeChoice, + onClickCustomDate = viewModel::onClickCustomDate, + onClickCustomTime = viewModel::onClickCustomTime, + customReschedule = viewModel::customReschedule, onClose = onClose, ) } @@ -65,6 +110,9 @@ fun PostponeTaskScreen( fun PostponeTaskScreen( state: PostponeTaskScreenUiState, onClose: () -> Unit, + onClickCustomDate: () -> Unit, + onClickCustomTime: () -> Unit, + customReschedule: () -> Unit, onPickPostponeChoice: (PostponeChoice) -> Unit, ) { Box( @@ -97,7 +145,10 @@ fun PostponeTaskScreen( ) { PostponeTaskScreenContent( state = state, - onPickPostponeChoice = onPickPostponeChoice + onClickCustomDate = onClickCustomDate, + onClickCustomTime = onClickCustomTime, + onPickPostponeChoice = onPickPostponeChoice, + customReschedule = customReschedule, ) } } @@ -106,6 +157,9 @@ fun PostponeTaskScreen( @Composable private fun PostponeTaskScreenContent( state: PostponeTaskScreenUiState, + onClickCustomDate: () -> Unit, + onClickCustomTime: () -> Unit, + customReschedule: () -> Unit, onPickPostponeChoice: (PostponeChoice) -> Unit ) { Column( @@ -124,31 +178,75 @@ private fun PostponeTaskScreenContent( Spacer(Modifier.height(AppTheme.dimens.spacingLarge)) - AnimatedContent( - targetState = state.postponeChoices, - label = "Choices content animation" - ) { - when (it) { - is PostponeChoiceListState.Idle -> Unit - is PostponeChoiceListState.Loading -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(AppTheme.dimens.contentMargin), - contentAlignment = Alignment.Center, - ) { - CircularLoadingIndicator() - } + when (state.postponeChoices) { + is PostponeChoiceListState.Idle -> Unit + is PostponeChoiceListState.Loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(AppTheme.dimens.contentMargin), + contentAlignment = Alignment.Center, + ) { + CircularLoadingIndicator() } + } - is PostponeChoiceListState.Success -> { - PostponePicker( - modifier = Modifier.fillMaxWidth(), - actions = it.choices, - onAmountChoice = onPickPostponeChoice, - ) + is PostponeChoiceListState.Success -> { + PostponePicker( + modifier = Modifier.fillMaxWidth(), + actions = state.postponeChoices.choices, + onAmountChoice = onPickPostponeChoice, + ) + } + } + + AnimatedVisibility(state.showCustomPostponeChoice) { + Column { + DateTimePicker( + label = stringResource(S.postpone_task_custom_date), + content = state.customPostponeDate?.let { dayAsText(state.customPostponeDate) } + ?: "--", + leadingIcon = { + Icon( + imageVector = Icons.Outlined.CalendarToday, + contentDescription = null, + ) + }, + onClick = onClickCustomDate, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppTheme.dimens.contentMargin) + ) + + Spacer(Modifier.height(AppTheme.dimens.spacingLarge)) + + DateTimePicker( + label = stringResource(S.postpone_task_custom_time), + content = state.customPostponeTime?.ofLocalizedTime() ?: "--", + leadingIcon = { + Icon( + imageVector = Icons.Outlined.AccessTime, + contentDescription = null, + ) + }, + onClick = onClickCustomTime, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppTheme.dimens.contentMargin) + ) + + Spacer(modifier = Modifier.height(AppTheme.dimens.spacingLarge)) + + PrimaryButton( + onClick = customReschedule, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppTheme.dimens.contentMargin) + ) { + Text("Reschedule") } } + } } } @@ -160,9 +258,14 @@ private fun PostponePicker( modifier: Modifier = Modifier ) { LazyColumn(modifier = modifier) { - items(actions) { postponeChoice -> + itemsIndexed(actions) { index, postponeChoice -> + if (index == actions.lastIndex) { + HorizontalDivider() + } + PostponePickerItem( - title = postponeChoice.formatToText(), + title = postponeChoice.postponeChoiceType.formatToText(), + description = postponeChoice.postponeDateTime?.formatToText(), modifier = Modifier.fillMaxWidth(), onClick = { onAmountChoice(postponeChoice) }) } @@ -172,16 +275,18 @@ private fun PostponePicker( @Composable private fun PostponePickerItem( title: String, + description: String?, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - Box( + Row( modifier = modifier .clickable { onClick() } .padding( horizontal = AppTheme.dimens.contentMargin, vertical = AppTheme.dimens.spacingLarge ), + verticalAlignment = Alignment.CenterVertically, ) { Text( text = title, @@ -189,18 +294,80 @@ private fun PostponePickerItem( maxLines = 1, overflow = TextOverflow.Ellipsis, ) + + if (description != null) { + Box( + modifier = Modifier + .padding(horizontal = AppTheme.dimens.spacingMedium) + .size(4.dp) + .background(MaterialTheme.colorScheme.onSurface, CircleShape) + ) + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + } } } @Composable -internal fun PostponeChoice.formatToText(): String { +private fun DateTimePicker( + label: String, + content: String, + leadingIcon: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TextField( + value = content, + onValueChange = {}, + readOnly = true, + label = { + Text(label) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + ) + }, + leadingIcon = leadingIcon, + modifier = modifier + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + if (upEvent != null) { + onClick() + } + } + }, + ) +} + +@Composable +internal fun PostponeChoiceType.formatToText(): String { return when (this) { - PostponeChoice.FifteenMinutes -> stringResource(S.postpone_task_fifteen_minutes) - PostponeChoice.OneHour -> stringResource(S.postpone_task_one_hour) - PostponeChoice.Tonight -> stringResource(S.postpone_task_tonight) - PostponeChoice.TomorrowMorning -> stringResource(S.postpone_task_tomorrow_morning) - PostponeChoice.NextWeekend -> stringResource(S.postpone_task_next_weekend) - PostponeChoice.NextWeek -> stringResource(S.postpone_task_next_week) + PostponeChoiceType.ThirtyMinutes -> stringResource(S.postpone_task_thirty_minutes) + PostponeChoiceType.OneHour -> stringResource(S.postpone_task_one_hour) + PostponeChoiceType.ThreeHours -> stringResource(S.postpone_task_three_hours) + PostponeChoiceType.Tonight -> stringResource(S.postpone_task_tonight) + PostponeChoiceType.TomorrowMorning -> stringResource(S.postpone_task_tomorrow_morning) + PostponeChoiceType.NextWeekend -> stringResource(S.postpone_task_next_weekend) + PostponeChoiceType.NextWeek -> stringResource(S.postpone_task_next_week) + PostponeChoiceType.Custom -> stringResource(S.postpone_task_custom) + } +} + +@Composable +internal fun LocalDateTime.formatToText(): String { + val time = toLocalTime() + return if (this.toLocalDate() != LocalDate.now()) { + "${dateWithMonthFormatter.format(this)} - ${time.ofLocalizedTime()}" + } else { + time.ofLocalizedTime() } } @@ -214,7 +381,9 @@ fun PostponeTaskScreenPreview( state = state, onClose = {}, onPickPostponeChoice = {}, + onClickCustomDate = {}, + onClickCustomTime = {}, + customReschedule = {}, ) } } - diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreenPreviewData.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreenPreviewData.kt index 0f728a87..6bebc9f9 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreenPreviewData.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreenPreviewData.kt @@ -1,7 +1,12 @@ package com.costular.atomtasks.postponetask.ui import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.costular.atomtasks.postponetask.domain.DefaultPostponeChoiceCalculator +import com.costular.atomtasks.postponetask.domain.GetPostponeChoiceListUseCase import com.costular.atomtasks.postponetask.domain.PostponeChoice +import com.costular.atomtasks.postponetask.domain.PostponeChoiceCalculator +import com.costular.atomtasks.postponetask.domain.PostponeChoiceType +import java.time.Clock internal class PostponeTaskScreenPreviewData : PreviewParameterProvider { override val values: Sequence = sequenceOf( @@ -13,11 +18,27 @@ internal class PostponeTaskScreenPreviewData : PreviewParameterProvider(PostponeTaskScreenUiState()) { @@ -53,24 +55,123 @@ class PostponeTaskViewModel @Inject constructor( viewModelScope.launch { val taskId = state.value.taskId - // Remove notification as soon as the user decides to postpone the task, not before - taskNotificationManager.removeTaskNotification(taskId) - - val reminder = postponeChoiceCalculator.calculatePostpone(postponeChoice) - - postponeTaskUseCase.invoke( - PostponeTaskUseCase.Params( - taskId = taskId, - day = reminder.toLocalDate(), - time = reminder.toLocalTime(), - ) - ).fold( - ifError = { - // For now we won't handle the error - }, - ifResult = { - sendEvent(PostponeTaskUiEvents.PostponedSuccessfully) - } + if (postponeChoice.postponeChoiceType is PostponeChoiceType.Custom) { + showCustomPostpone() + return@launch + } + + analytics.track( + PostponeDefaultOptionClicked(postponeChoice.postponeChoiceType.toString()) + ) + + rescheduleWithDateTime( + taskId, + postponeChoice.postponeDateTime!!.toLocalDate(), + postponeChoice.postponeDateTime.toLocalTime() + ) + } + } + + private suspend fun rescheduleWithDateTime( + taskId: Long, + day: LocalDate, + time: LocalTime, + ) { + taskNotificationManager.removeTaskNotification(taskId) + + postponeTaskUseCase.invoke( + PostponeTaskUseCase.Params( + taskId = taskId, + day = day, + time = time, + ) + ).fold( + ifError = { + // For now we won't handle the error + }, + ifResult = { + sendEvent(PostponeTaskUiEvents.PostponedSuccessfully) + } + ) + } + + private fun showCustomPostpone() { + analytics.track(PostponeCustomOptionClicked) + val automaticPostpone = LocalDateTime.now().plusMinutes(15) + + setState { + copy( + showCustomPostponeChoice = true, + customPostponeDate = automaticPostpone.toLocalDate(), + customPostponeTime = automaticPostpone.toLocalTime(), + ) + } + } + + fun onClickCustomDate() { + analytics.track(PostponeCustomDatePickerOpened) + setState { + copy( + isSelectDayDialogOpen = true, + ) + } + } + + fun dismissCustomDate() { + setState { + copy( + isSelectDayDialogOpen = false, + ) + } + } + + fun onUpdateDate(date: LocalDate) { + setState { + copy( + isSelectDayDialogOpen = false, + customPostponeDate = date + ) + } + } + + fun onClickCustomTime() { + analytics.track(PostponeCustomTimePickerOpened) + setState { + copy( + isSelectTimeDialogOpen = true, + ) + } + } + + fun dismissCustomTime() { + setState { + copy( + isSelectTimeDialogOpen = false, + ) + } + } + + fun onUpdateTime(time: LocalTime) { + setState { + copy( + isSelectTimeDialogOpen = false, + customPostponeTime = time + ) + } + } + + fun customReschedule() { + viewModelScope.launch { + val date = requireNotNull(state.value.customPostponeDate) + val time = requireNotNull(state.value.customPostponeTime) + val taskId = state.value.taskId + + analytics.track(PostponeCustomRescheduled(date, time)) + + rescheduleWithDateTime( + taskId, + date, + time, ) } } diff --git a/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/DefaultPostponeChoiceCalculatorTest.kt b/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/DefaultPostponeChoiceTypeCalculatorTest.kt similarity index 67% rename from feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/DefaultPostponeChoiceCalculatorTest.kt rename to feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/DefaultPostponeChoiceTypeCalculatorTest.kt index df8f1311..b28c2c31 100644 --- a/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/DefaultPostponeChoiceCalculatorTest.kt +++ b/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/DefaultPostponeChoiceTypeCalculatorTest.kt @@ -10,7 +10,7 @@ import org.junit.Before import org.junit.Ignore import org.junit.Test -class DefaultPostponeChoiceCalculatorTest { +class DefaultPostponeChoiceTypeCalculatorTest { lateinit var sut: PostponeChoiceCalculator @@ -22,19 +22,19 @@ class DefaultPostponeChoiceCalculatorTest { } @Test - fun `Should return datetime today in 15 minutes when calculate postpone 15 minutes is selected`() { - val expected = LocalDateTime.now().plusMinutes(15) - val result = sut.calculatePostpone(PostponeChoice.FifteenMinutes) + fun `Should return datetime today in 30 minues when calculate postpone 30 minutes is selected`() { + val expected = LocalDateTime.now().plusMinutes(30) + val result = sut.calculatePostpone(PostponeChoiceType.ThirtyMinutes) - Truth.assertThat(result.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) + Truth.assertThat(result?.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) } @Test fun `Should return datetime today in 60 minutes when calculate postpone one hour is selected`() { val expected = LocalDateTime.now().plusHours(1) - val result = sut.calculatePostpone(PostponeChoice.OneHour) + val result = sut.calculatePostpone(PostponeChoiceType.OneHour) - Truth.assertThat(result.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) + Truth.assertThat(result?.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) } @Test @@ -46,9 +46,9 @@ class DefaultPostponeChoiceCalculatorTest { givenPostponeChoiceCalculator(clock) val expected = LocalDate.now().atTime(20, 0) - val result = sut.calculatePostpone(PostponeChoice.Tonight) + val result = sut.calculatePostpone(PostponeChoiceType.Tonight) - Truth.assertThat(result.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) + Truth.assertThat(result?.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) } @Test @@ -60,9 +60,9 @@ class DefaultPostponeChoiceCalculatorTest { givenPostponeChoiceCalculator(clock) val expected = LocalDate.now().plusDays(1).atTime(20, 0) - val result = sut.calculatePostpone(PostponeChoice.Tonight) + val result = sut.calculatePostpone(PostponeChoiceType.Tonight) - Truth.assertThat(result.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) + Truth.assertThat(result?.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) } @Test @@ -74,9 +74,9 @@ class DefaultPostponeChoiceCalculatorTest { givenPostponeChoiceCalculator(clock) val expected = LocalDate.now().plusDays(1).atTime(8, 0) - val result = sut.calculatePostpone(PostponeChoice.TomorrowMorning) + val result = sut.calculatePostpone(PostponeChoiceType.TomorrowMorning) - Truth.assertThat(result.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) + Truth.assertThat(result?.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) } @Test @@ -88,9 +88,9 @@ class DefaultPostponeChoiceCalculatorTest { givenPostponeChoiceCalculator(clock) val expected = LocalDate.now().plusDays(1).atTime(8, 0) - val result = sut.calculatePostpone(PostponeChoice.TomorrowMorning) + val result = sut.calculatePostpone(PostponeChoiceType.TomorrowMorning) - Truth.assertThat(result.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) + Truth.assertThat(result?.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) } @Test @@ -102,9 +102,9 @@ class DefaultPostponeChoiceCalculatorTest { givenPostponeChoiceCalculator(clock) val expected = LocalDate.of(2023, 10, 15).atTime(8, 0) - val result = sut.calculatePostpone(PostponeChoice.NextWeekend) + val result = sut.calculatePostpone(PostponeChoiceType.NextWeekend) - Truth.assertThat(result.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) + Truth.assertThat(result?.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) } @Test @@ -116,9 +116,9 @@ class DefaultPostponeChoiceCalculatorTest { givenPostponeChoiceCalculator(clock) val expected = LocalDate.of(2023, 10, 14).atTime(8, 0) - val result = sut.calculatePostpone(PostponeChoice.NextWeekend) + val result = sut.calculatePostpone(PostponeChoiceType.NextWeekend) - Truth.assertThat(result.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) + Truth.assertThat(result?.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) } @Ignore("Flaky test on the CI") @@ -131,9 +131,9 @@ class DefaultPostponeChoiceCalculatorTest { givenPostponeChoiceCalculator(clock) val expected = LocalDate.of(2023, 10, 16).atTime(8, 0) - val result = sut.calculatePostpone(PostponeChoice.NextWeek) + val result = sut.calculatePostpone(PostponeChoiceType.NextWeek) - Truth.assertThat(result.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) + Truth.assertThat(result?.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) } @Ignore("Flaky test on the CI") @@ -146,9 +146,9 @@ class DefaultPostponeChoiceCalculatorTest { givenPostponeChoiceCalculator(clock) val expected = LocalDate.of(2023, 10, 23).atTime(8, 0) - val result = sut.calculatePostpone(PostponeChoice.NextWeek) + val result = sut.calculatePostpone(PostponeChoiceType.NextWeek) - Truth.assertThat(result.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) + Truth.assertThat(result?.withoutMilliseconds()).isEqualTo(expected.withoutMilliseconds()) } private fun LocalDateTime.withoutMilliseconds(): LocalDateTime = truncatedTo(ChronoUnit.SECONDS) diff --git a/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCaseTest.kt b/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCaseTest.kt new file mode 100644 index 00000000..c160777e --- /dev/null +++ b/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCaseTest.kt @@ -0,0 +1,50 @@ +package com.costular.atomtasks.postponetask.domain + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.time.Clock +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset + + +class GetPostponeChoiceListUseCaseTest { + + private lateinit var clock: Clock + + lateinit var sut: GetPostponeChoiceListUseCase + + @Before + fun setUp() { + clock = Clock.systemDefaultZone() + initializeViewModel() + } + + @Test + fun `Should not show tonight when the time has passed when returning postpone choices`() = + runTest { + clock = Clock.fixed( + LocalDateTime.of( + FixedDate, LocalTime.of(21, 0) + ).toInstant(ZoneOffset.UTC), + ZoneId.of("UTC") + ) + initializeViewModel() + + val actual = sut.invoke(Unit) + + assertThat(actual.find { it.postponeChoiceType == PostponeChoiceType.Tonight }).isNull() + } + + private fun initializeViewModel() { + sut = GetPostponeChoiceListUseCase(DefaultPostponeChoiceCalculator(clock)) + } + + private companion object { + val FixedDate = LocalDate.of(2024, 10, 13) + } +} \ No newline at end of file diff --git a/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModelTest.kt b/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModelTest.kt index f575e773..f31778df 100644 --- a/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModelTest.kt +++ b/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModelTest.kt @@ -1,22 +1,26 @@ package com.costular.atomtasks.postponetask.ui +import com.costular.atomtasks.analytics.AtomAnalytics import com.costular.atomtasks.core.testing.MviViewModelTest import com.costular.atomtasks.core.toResult import com.costular.atomtasks.notifications.TaskNotificationManager -import com.costular.atomtasks.postponetask.domain.DefaultPostponeChoiceCalculator import com.costular.atomtasks.postponetask.domain.GetPostponeChoiceListUseCase import com.costular.atomtasks.postponetask.domain.PostponeChoice +import com.costular.atomtasks.postponetask.domain.PostponeChoiceType +import com.costular.atomtasks.tasks.analytics.NotificationsActionsPostpone import com.costular.atomtasks.tasks.usecase.PostponeTaskUseCase import com.google.common.truth.Truth.assertThat import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk -import java.time.Clock import kotlinx.coroutines.delay import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime class PostponeTaskViewModelTest : MviViewModelTest() { @@ -26,22 +30,18 @@ class PostponeTaskViewModelTest : MviViewModelTest() { mockk(relaxUnitFun = true) private val postponeTaskUseCase: PostponeTaskUseCase = mockk(relaxUnitFun = true) private val taskNotificationManager: TaskNotificationManager = mockk(relaxUnitFun = true) + private val analytics: AtomAnalytics = mockk(relaxed = true) @Before fun setup() { - sut = PostponeTaskViewModel( - getPostponeChoiceListUseCase = getPostponeChoiceListUseCase, - postponeTaskUseCase = postponeTaskUseCase, - taskNotificationManager = taskNotificationManager, - postponeChoiceCalculator = DefaultPostponeChoiceCalculator(Clock.systemDefaultZone()), - ) + givenPostponeChoicesSucceeds() + givenPostponeSucceeds() + initializeViewModel() } @Test fun `Should expose postpone choices when usecase return successfully`() = runTest { - coEvery { getPostponeChoiceListUseCase(Unit) } returns FakeChoices - - sut.initialize(123L) + sut.initialize(ANY_TASK_ID) assertThat(sut.state.value.postponeChoices).isEqualTo( PostponeChoiceListState.Success( @@ -59,7 +59,7 @@ class PostponeTaskViewModelTest : MviViewModelTest() { FakeChoices } - sut.initialize(123) + sut.initialize(ANY_TASK_ID) assertThat(sut.state.value.postponeChoices).isEqualTo(PostponeChoiceListState.Loading) @@ -72,17 +72,126 @@ class PostponeTaskViewModelTest : MviViewModelTest() { @Test fun `Should cancel the notification manager when the task is postponed`() = runTest { - val taskId = 123L - coEvery { getPostponeChoiceListUseCase(Unit) } returns FakeChoices + sut.initialize(ANY_TASK_ID) + sut.onSelectPostponeChoice(FakeChoices.first()) + + coVerify(exactly = 1) { taskNotificationManager.removeTaskNotification(ANY_TASK_ID) } + } + + private fun givenPostponeSucceeds() { coEvery { postponeTaskUseCase.invoke(any()) } returns Unit.toResult() + } + + private fun givenPostponeChoicesSucceeds() { + coEvery { getPostponeChoiceListUseCase(Unit) } returns FakeChoices + } + + @Test + fun `Should show custom section when tap on custom postpone choice`() = runTest { + sut.initialize(ANY_TASK_ID) + val customChoice = FakeChoices.find { it.postponeChoiceType == PostponeChoiceType.Custom }!! + sut.onSelectPostponeChoice(customChoice) - sut.initialize(taskId) - sut.onSelectPostponeChoice(PostponeChoice.OneHour) + assertThat(sut.state.value.showCustomPostponeChoice).isTrue() + } + + @Test + fun `Should track notifications actions postpone when initialize options`() = runTest { + sut.initialize(ANY_TASK_ID) - coVerify(exactly = 1) { taskNotificationManager.removeTaskNotification(taskId) } + coEvery { analytics.track(NotificationsActionsPostpone) } + } + + @Test + fun `Should track custom postpone choice when tap on custom postpone choice`() = runTest { + sut.initialize(ANY_TASK_ID) + + sut.onSelectPostponeChoice(CustomChoice) + + coEvery { analytics.track(PostponeCustomOptionClicked) } + } + + @Test + fun `Should track default postpone choice when tap on default postpone choice`() = runTest { + sut.initialize(ANY_TASK_ID) + val choice = FakeChoices.first() + sut.onSelectPostponeChoice(choice) + + coEvery { analytics.track(PostponeDefaultOptionClicked("OneHour")) } + } + + @Test + fun `Should track custom date when tap on custom date`() = runTest { + sut.initialize(ANY_TASK_ID) + sut.onClickCustomDate() + + coEvery { analytics.track(PostponeCustomDatePickerOpened) } + } + + @Test + fun `Should track custom time when tap on custom time`() = runTest { + sut.initialize(ANY_TASK_ID) + sut.onClickCustomTime() + + coEvery { analytics.track(PostponeCustomTimePickerOpened) } + } + + @Test + fun `Should call schedule when tap on custom reschedule given date & time were selected`() = + runTest { + val date = LocalDate.now() + val time = LocalTime.now() + + with(sut) { + initialize(ANY_TASK_ID) + onSelectPostponeChoice(CustomChoice) + onUpdateDate(date) + onUpdateTime(time) + } + + coEvery { + postponeTaskUseCase.invoke( + PostponeTaskUseCase.Params( + ANY_TASK_ID, + date, + time, + ) + ) + } + } + + @Test + fun `Should track custom reschedule when tap on custom reschedule given date & time were selected`() = + runTest { + val date = LocalDate.now() + val time = LocalTime.now() + + with(sut) { + initialize(ANY_TASK_ID) + onSelectPostponeChoice(CustomChoice) + onUpdateDate(date) + onUpdateTime(time) + } + + coEvery { analytics.track(PostponeCustomRescheduled(date, time)) } + } + + private fun initializeViewModel() { + sut = PostponeTaskViewModel( + getPostponeChoiceListUseCase = getPostponeChoiceListUseCase, + postponeTaskUseCase = postponeTaskUseCase, + taskNotificationManager = taskNotificationManager, + analytics = analytics, + ) } private companion object { - val FakeChoices = listOf(PostponeChoice.OneHour, PostponeChoice.Tonight) + const val ANY_TASK_ID = 123L + val FakeChoices = listOf( + PostponeChoice(PostponeChoiceType.OneHour, LocalDateTime.now().plusMinutes(15)), + PostponeChoice(PostponeChoiceType.Tonight, LocalDate.now().atTime(20, 0)), + PostponeChoice(PostponeChoiceType.Custom, null), + ) + val CustomChoice = FakeChoices.find { it.postponeChoiceType == PostponeChoiceType.Custom }!! } } From 8b18dbb3b47d025eb5a9d1aa77939808977fe05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Francisco=20Concepci=C3=B3n?= Date: Fri, 18 Oct 2024 14:43:50 +0100 Subject: [PATCH 4/4] Fix lint errors --- .../domain/GetPostponeChoiceListUseCase.kt | 1 - .../postponetask/domain/PostponeChoice.kt | 2 +- .../postponetask/ui/PostponeAnalytics.kt | 2 +- .../postponetask/ui/PostponeTaskActivity.kt | 2 - .../postponetask/ui/PostponeTaskScreen.kt | 99 +++++++++++-------- .../ui/PostponeTaskScreenPreviewData.kt | 2 - .../postponetask/ui/PostponeTaskViewModel.kt | 9 +- .../GetPostponeChoiceListUseCaseTest.kt | 2 +- 8 files changed, 66 insertions(+), 53 deletions(-) diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCase.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCase.kt index 5fcda3f6..dc44a9e6 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCase.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCase.kt @@ -2,7 +2,6 @@ package com.costular.atomtasks.postponetask.domain import com.costular.atomtasks.core.usecase.UseCase import java.time.LocalDate -import java.time.LocalDateTime import javax.inject.Inject class GetPostponeChoiceListUseCase @Inject constructor( diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoice.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoice.kt index 0e32c735..c2df9f94 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoice.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/domain/PostponeChoice.kt @@ -5,4 +5,4 @@ import java.time.LocalDateTime data class PostponeChoice( val postponeChoiceType: PostponeChoiceType, val postponeDateTime: LocalDateTime?, -) \ No newline at end of file +) diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeAnalytics.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeAnalytics.kt index 5588db85..6c9a8d7a 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeAnalytics.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeAnalytics.kt @@ -26,4 +26,4 @@ data class PostponeCustomRescheduled( "date" to date.toString(), "time" to time.toString(), ) -) \ No newline at end of file +) diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskActivity.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskActivity.kt index 01edf72a..122435b8 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskActivity.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskActivity.kt @@ -1,7 +1,5 @@ package com.costular.atomtasks.postponetask.ui -import android.content.Context -import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreen.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreen.kt index dcf50ced..fc3a6061 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreen.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -200,53 +201,67 @@ private fun PostponeTaskScreenContent( } } - AnimatedVisibility(state.showCustomPostponeChoice) { - Column { - DateTimePicker( - label = stringResource(S.postpone_task_custom_date), - content = state.customPostponeDate?.let { dayAsText(state.customPostponeDate) } - ?: "--", - leadingIcon = { - Icon( - imageVector = Icons.Outlined.CalendarToday, - contentDescription = null, - ) - }, - onClick = onClickCustomDate, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AppTheme.dimens.contentMargin) - ) + CustomPostponeChoice( + state = state, + onClickCustomDate = onClickCustomDate, + onClickCustomTime = onClickCustomTime, + customReschedule = customReschedule + ) + } +} + +@Composable +private fun ColumnScope.CustomPostponeChoice( + state: PostponeTaskScreenUiState, + onClickCustomDate: () -> Unit, + onClickCustomTime: () -> Unit, + customReschedule: () -> Unit +) { + AnimatedVisibility(state.showCustomPostponeChoice) { + Column { + DateTimePicker( + label = stringResource(S.postpone_task_custom_date), + content = state.customPostponeDate?.let { dayAsText(state.customPostponeDate) } + ?: "--", + leadingIcon = { + Icon( + imageVector = Icons.Outlined.CalendarToday, + contentDescription = null, + ) + }, + onClick = onClickCustomDate, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppTheme.dimens.contentMargin) + ) - Spacer(Modifier.height(AppTheme.dimens.spacingLarge)) + Spacer(Modifier.height(AppTheme.dimens.spacingLarge)) - DateTimePicker( - label = stringResource(S.postpone_task_custom_time), - content = state.customPostponeTime?.ofLocalizedTime() ?: "--", - leadingIcon = { - Icon( - imageVector = Icons.Outlined.AccessTime, - contentDescription = null, - ) - }, - onClick = onClickCustomTime, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AppTheme.dimens.contentMargin) - ) + DateTimePicker( + label = stringResource(S.postpone_task_custom_time), + content = state.customPostponeTime?.ofLocalizedTime() ?: "--", + leadingIcon = { + Icon( + imageVector = Icons.Outlined.AccessTime, + contentDescription = null, + ) + }, + onClick = onClickCustomTime, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppTheme.dimens.contentMargin) + ) - Spacer(modifier = Modifier.height(AppTheme.dimens.spacingLarge)) + Spacer(modifier = Modifier.height(AppTheme.dimens.spacingLarge)) - PrimaryButton( - onClick = customReschedule, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AppTheme.dimens.contentMargin) - ) { - Text("Reschedule") - } + PrimaryButton( + onClick = customReschedule, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppTheme.dimens.contentMargin) + ) { + Text("Reschedule") } - } } } diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreenPreviewData.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreenPreviewData.kt index 6bebc9f9..76d89553 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreenPreviewData.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskScreenPreviewData.kt @@ -4,8 +4,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.costular.atomtasks.postponetask.domain.DefaultPostponeChoiceCalculator import com.costular.atomtasks.postponetask.domain.GetPostponeChoiceListUseCase import com.costular.atomtasks.postponetask.domain.PostponeChoice -import com.costular.atomtasks.postponetask.domain.PostponeChoiceCalculator -import com.costular.atomtasks.postponetask.domain.PostponeChoiceType import java.time.Clock internal class PostponeTaskScreenPreviewData : PreviewParameterProvider { diff --git a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModel.kt b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModel.kt index fddfd9a7..e0805b7b 100644 --- a/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModel.kt +++ b/feature/postpone-task/src/main/java/com/costular/atomtasks/postponetask/ui/PostponeTaskViewModel.kt @@ -1,7 +1,6 @@ package com.costular.atomtasks.postponetask.ui import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel import com.costular.atomtasks.analytics.AtomAnalytics import com.costular.atomtasks.core.ui.mvi.MviViewModel import com.costular.atomtasks.postponetask.domain.GetPostponeChoiceListUseCase @@ -17,6 +16,7 @@ import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime +@Suppress("TooManyFunctions") @HiltViewModel class PostponeTaskViewModel @Inject constructor( private val getPostponeChoiceListUseCase: GetPostponeChoiceListUseCase, @@ -24,7 +24,6 @@ class PostponeTaskViewModel @Inject constructor( private val taskNotificationManager: TaskNotificationManager, private val analytics: AtomAnalytics, ) : MviViewModel(PostponeTaskScreenUiState()) { - fun initialize(taskId: Long) { analytics.track(NotificationsActionsPostpone) setTaskId(taskId) @@ -97,7 +96,7 @@ class PostponeTaskViewModel @Inject constructor( private fun showCustomPostpone() { analytics.track(PostponeCustomOptionClicked) - val automaticPostpone = LocalDateTime.now().plusMinutes(15) + val automaticPostpone = LocalDateTime.now().plusMinutes(DefaultPostponeMinutes) setState { copy( @@ -175,4 +174,8 @@ class PostponeTaskViewModel @Inject constructor( ) } } + + private companion object { + const val DefaultPostponeMinutes = 15L + } } diff --git a/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCaseTest.kt b/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCaseTest.kt index c160777e..b02625aa 100644 --- a/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCaseTest.kt +++ b/feature/postpone-task/src/test/java/com/costular/atomtasks/postponetask/domain/GetPostponeChoiceListUseCaseTest.kt @@ -47,4 +47,4 @@ class GetPostponeChoiceListUseCaseTest { private companion object { val FixedDate = LocalDate.of(2024, 10, 13) } -} \ No newline at end of file +}