From 9132c809eb4ff33bec23624cefeef1a82c859d7c Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Mon, 22 Jun 2026 21:28:54 -0700 Subject: [PATCH 1/2] fix: use proper value set on settings instead of the remember value --- .../ui/e2e/history/HistoryScreenE2ETest.kt | 8 ++- .../data/repository/SettingsRepository.kt | 4 +- .../data/repository/SettingsRepositoryImpl.kt | 14 ++++- .../app/minus/domain/model/UserSettings.kt | 9 ++- .../minus/presentation/ui/history/History.kt | 30 +++++---- .../ui/history/HistoryMviContract.kt | 12 ++-- .../ui/history/HistoryViewModel.kt | 14 +++-- .../ui/settings/SettingsViewModel.kt | 61 ++++++++++--------- 8 files changed, 93 insertions(+), 59 deletions(-) diff --git a/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/HistoryScreenE2ETest.kt b/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/HistoryScreenE2ETest.kt index ab4c48c..3768bb6 100644 --- a/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/HistoryScreenE2ETest.kt +++ b/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/HistoryScreenE2ETest.kt @@ -151,8 +151,10 @@ class HistoryScreenE2ETest { val expectedStart = prettyDate(periodStart) val expectedEnd = prettyDate(periodEnd) - composeTestRule.onAllNodesWithText(expectedStart, substring = true).onLast().assertIsDisplayed() - composeTestRule.onAllNodesWithText(expectedEnd, substring = true).onLast().assertIsDisplayed() + composeTestRule.onAllNodesWithText(expectedStart, substring = true).onLast() + .assertIsDisplayed() + composeTestRule.onAllNodesWithText(expectedEnd, substring = true).onLast() + .assertIsDisplayed() Truth.assertThat(transactions).hasSize(4) } @@ -192,7 +194,7 @@ class HistoryScreenE2ETest { ) composeTestRule.waitForIdle() -composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.mainClock.advanceTimeBy(500) composeTestRule.waitForIdle() val totalBudgetLabel = composeTestRule.activity.getString(R.string.total_budget) diff --git a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt index c3a7b3e..ae398a3 100644 --- a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt @@ -39,5 +39,7 @@ interface SettingsRepository { suspend fun setDynamicColorEnabled(enabled: Boolean) + suspend fun setRecurrentPaymentsViewMode(mode: com.serranoie.app.minus.presentation.ui.history.RecurrentPaymentsViewMode) + suspend fun clearEarlyFinish() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt index a8e9264..e8aec9b 100644 --- a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt @@ -15,6 +15,7 @@ import com.serranoie.app.minus.domain.time.CURRENT_PERIOD_ROLLOVER_CARRY_FORWARD import com.serranoie.app.minus.domain.time.PENDING_ROLLOVER_AMOUNT_KEY_NAME import com.serranoie.app.minus.domain.time.PENDING_ROLLOVER_STRATEGY_KEY_NAME import com.serranoie.app.minus.presentation.settingsDataStore +import com.serranoie.app.minus.presentation.ui.history.RecurrentPaymentsViewMode import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -52,6 +53,8 @@ private val NOTIFICATION_MINUTE = intPreferencesKey(NOTIFICATION_MINUTE_KEY_NAME private val THEME_MODE = stringPreferencesKey(THEME_MODE_KEY_NAME) private val TYPOGRAPHY_MODE = stringPreferencesKey(TYPOGRAPHY_MODE_KEY_NAME) private val DYNAMIC_COLOR = booleanPreferencesKey(DYNAMIC_COLOR_KEY_NAME) +private val RECURRENT_PAYMENTS_VIEW_MODE = + stringPreferencesKey(RECURRENT_PAYMENTS_VIEW_MODE_KEY_NAME) private val CURRENT_PERIOD_ROLLOVER_AMOUNT = stringPreferencesKey(CURRENT_PERIOD_ROLLOVER_AMOUNT_KEY_NAME) private val CURRENT_PERIOD_ROLLOVER_CARRY_FORWARD = @@ -80,7 +83,10 @@ class SettingsRepositoryImpl @Inject constructor( themeMode = preferences[THEME_MODE]?.toThemeMode() ?: ThemeMode.SYSTEM, typographyMode = preferences[TYPOGRAPHY_MODE]?.toTypographyMode() ?: TypographyMode.EXPRESSIVE, - dynamicColorEnabled = preferences[DYNAMIC_COLOR] ?: false + dynamicColorEnabled = preferences[DYNAMIC_COLOR] ?: false, + recurrentPaymentsViewMode = RecurrentPaymentsViewMode.fromName( + preferences[RECURRENT_PAYMENTS_VIEW_MODE] + ) ) } } @@ -182,6 +188,12 @@ class SettingsRepositoryImpl @Inject constructor( } } + override suspend fun setRecurrentPaymentsViewMode(mode: RecurrentPaymentsViewMode) { + context.settingsDataStore.edit { preferences -> + preferences[RECURRENT_PAYMENTS_VIEW_MODE] = mode.name + } + } + override suspend fun clearEarlyFinish() { context.settingsDataStore.edit { preferences -> preferences[EARLY_FINISH_ACTIVE] = false diff --git a/app/src/main/java/com/serranoie/app/minus/domain/model/UserSettings.kt b/app/src/main/java/com/serranoie/app/minus/domain/model/UserSettings.kt index de3a98e..3c326f3 100644 --- a/app/src/main/java/com/serranoie/app/minus/domain/model/UserSettings.kt +++ b/app/src/main/java/com/serranoie/app/minus/domain/model/UserSettings.kt @@ -1,9 +1,7 @@ package com.serranoie.app.minus.domain.model -/** - * User preferences and settings loaded from DataStore. - * These represent the user's configuration choices and app state. - */ +import com.serranoie.app.minus.presentation.ui.history.RecurrentPaymentsViewMode + data class UserSettings( val onboardingCompleted: Boolean = false, val earlyFinishActive: Boolean = false, @@ -15,7 +13,8 @@ data class UserSettings( val notificationMinute: Int = DEFAULT_NOTIFICATION_MINUTE, val themeMode: ThemeMode = ThemeMode.SYSTEM, val typographyMode: TypographyMode = TypographyMode.EXPRESSIVE, - val dynamicColorEnabled: Boolean = false + val dynamicColorEnabled: Boolean = false, + val recurrentPaymentsViewMode: RecurrentPaymentsViewMode = RecurrentPaymentsViewMode.VERTICAL_LIST, ) { companion object { const val DEFAULT_NOTIFICATION_HOUR = 9 diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/History.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/History.kt index 8cedda9..d2eccc5 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/History.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/History.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api 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 @@ -46,14 +45,11 @@ fun History( onProcessIntent: (HistoryUiIntent) -> Unit = {}, ) { val resources = LocalResources.current - val recurrentPaymentsViewMode = remember { - RecurrentPaymentsViewMode.HORIZONTAL_LIST - } val scrollState = rememberLazyListState() val currencyCode = uiState.budgetSettings?.currencyCode ?: "USD" val currencyFormat = remember(currencyCode) { symbolOnlyCurrencyFormat(currencyCode) } - LaunchedEffect(uiState.groupedCurrentTransactions.keys, readOnly) { + LaunchedEffect(uiState.groupedCurrentTransactions.keys, readOnly, onProcessIntent) { val sortedDates = uiState.groupedCurrentTransactions.keys.filterNotNull().sortedDescending() val current = uiState.expandedDates if (current.isEmpty()) { @@ -84,9 +80,11 @@ fun History( upcomingRecurrentInPeriod = uiState.upcomingRecurrentInPeriod, showUpcomingRecurrentInPeriod = uiState.showUpcomingRecurrentInPeriod, onToggleShowUpcomingRecurrentInPeriod = { - onProcessIntent(HistoryUiIntent.ToggleUpcomingRecurrentInPeriod(!uiState.showUpcomingRecurrentInPeriod)) + onProcessIntent( + HistoryUiIntent.ToggleUpcomingRecurrentInPeriod(!uiState.showUpcomingRecurrentInPeriod) + ) }, - recurrentPaymentsViewMode = recurrentPaymentsViewMode, + recurrentPaymentsViewMode = uiState.recurrentPaymentsViewMode, currencyFormat = currencyFormat, onDelete = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToDelete(tx)) }, onEdit = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToEdit(tx)) }, @@ -107,7 +105,8 @@ fun History( tx, resources.getString( R.string.expense_deleted_format, - tx.comment.ifEmpty { resources.getString(R.string.generic_expense) }), + tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } + ), ) { onCancelPendingDelete() } @@ -121,9 +120,11 @@ fun History( futureRecurrentOutOfPeriod = uiState.futureRecurrentOutOfPeriod, showOutOfPeriodSubscriptions = uiState.showOutOfPeriodSubscriptions, onToggleShowOutOfPeriodSubscriptions = { - onProcessIntent(HistoryUiIntent.ToggleOutOfPeriodSubscriptions(!uiState.showOutOfPeriodSubscriptions)) + onProcessIntent( + HistoryUiIntent.ToggleOutOfPeriodSubscriptions(!uiState.showOutOfPeriodSubscriptions) + ) }, - recurrentPaymentsViewMode = recurrentPaymentsViewMode, + recurrentPaymentsViewMode = uiState.recurrentPaymentsViewMode, currencyFormat = currencyFormat, onDelete = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToDelete(tx)) }, onEdit = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToEdit(tx)) }, @@ -152,7 +153,8 @@ fun History( tx, resources.getString( R.string.expense_deleted_format, - tx.comment.ifEmpty { resources.getString(R.string.generic_expense) }), + tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } + ), ) { onCancelPendingDelete() } @@ -196,7 +198,8 @@ fun History( tx, resources.getString( R.string.expense_deleted_format, - tx.comment.ifEmpty { resources.getString(R.string.generic_expense) }), + tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } + ), ) { onCancelPendingDelete() } onProcessIntent(HistoryUiIntent.DeleteTransaction(tx)) } @@ -215,7 +218,8 @@ fun History( onShowInfoSnackbar( resources.getString( R.string.expense_modified_format, - tx.comment.ifEmpty { resources.getString(R.string.generic_expense) }) + tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } + ) ) onProcessIntent(HistoryUiIntent.SetEditingTransaction(null)) }, diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryMviContract.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryMviContract.kt index 26ed0c4..afb4d89 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryMviContract.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryMviContract.kt @@ -1,6 +1,9 @@ package com.serranoie.app.minus.presentation.ui.history +import com.serranoie.app.minus.domain.model.BudgetSettings +import com.serranoie.app.minus.domain.model.BudgetState import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.theme.component.expense.UpcomingRecurrentItem import java.time.LocalDate sealed interface HistoryUiIntent { @@ -27,8 +30,8 @@ sealed interface HistoryUiEffect { } data class HistoryUiState( - val budgetSettings: com.serranoie.app.minus.domain.model.BudgetSettings? = null, - val budgetState: com.serranoie.app.minus.domain.model.BudgetState? = null, + val budgetSettings: BudgetSettings? = null, + val budgetState: BudgetState? = null, val currentPeriodId: Long = 0L, val currentPeriodStartedAtMillis: Long = 0L, @@ -47,10 +50,11 @@ data class HistoryUiState( val showOutOfPeriodSubscriptions: Boolean = false, val showUpcomingRecurrentInPeriod: Boolean = true, val lockSwipeable: Boolean = true, + val recurrentPaymentsViewMode: RecurrentPaymentsViewMode = RecurrentPaymentsViewMode.VERTICAL_LIST, val displayTransactions: List = emptyList(), val groupedCurrentTransactions: Map> = emptyMap(), val groupedPastTransactions: Map> = emptyMap(), - val upcomingRecurrentInPeriod: List = emptyList(), - val futureRecurrentOutOfPeriod: List = emptyList(), + val upcomingRecurrentInPeriod: List = emptyList(), + val futureRecurrentOutOfPeriod: List = emptyList(), ) diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryViewModel.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryViewModel.kt index 489eea3..2feaa9f 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryViewModel.kt @@ -2,6 +2,7 @@ package com.serranoie.app.minus.presentation.ui.history import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.serranoie.app.minus.data.repository.SettingsRepository import com.serranoie.app.minus.domain.model.BudgetSettings import com.serranoie.app.minus.domain.model.BudgetState import com.serranoie.app.minus.domain.model.Transaction @@ -17,13 +18,13 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch -import java.math.BigDecimal import java.time.LocalDate import javax.inject.Inject @HiltViewModel class HistoryViewModel @Inject constructor( private val budgetTransactionHandler: BudgetTransactionHandler, + private val settingsRepository: SettingsRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(HistoryUiState()) @@ -43,13 +44,15 @@ class HistoryViewModel @Inject constructor( combine( budgetTransactionHandler.budgetRepository.getTransactions(), budgetTransactionHandler.budgetRepository.getBudgetSettings(), - ) { transactions, settings -> - transactions to settings - }.collect { (transactions, settings) -> + settingsRepository.observeSettings(), + ) { transactions, settings, userSettings -> + Triple(transactions, settings, userSettings) + }.collect { (transactions, settings, userSettings) -> recomputeDerivedState( transactions = transactions, budgetSettings = settings, budgetState = null, + userSettings = userSettings, ) } } @@ -144,6 +147,7 @@ class HistoryViewModel @Inject constructor( transactions: List, budgetSettings: BudgetSettings?, budgetState: BudgetState?, + userSettings: com.serranoie.app.minus.domain.model.UserSettings?, ) { val current = _uiState.value val pendingRemoved = current.pendingRemovedTransactions @@ -199,6 +203,8 @@ class HistoryViewModel @Inject constructor( upcomingRecurrentInPeriod = upcomingInPeriod, futureRecurrentOutOfPeriod = futureOutOfPeriod, expandedDates = autoExpanded, + recurrentPaymentsViewMode = userSettings?.recurrentPaymentsViewMode + ?: RecurrentPaymentsViewMode.VERTICAL_LIST, ) } diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModel.kt index 2eed074..a8ff1a2 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModel.kt @@ -12,11 +12,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.serranoie.app.minus.domain.model.PeriodMappingMode import com.serranoie.app.minus.domain.usecase.UpdatePeriodEndNotificationTimeUseCase +import com.serranoie.app.minus.data.repository.SettingsRepository import com.serranoie.app.minus.presentation.CREDIT_QUICK_TOGGLE_FEATURE_KEY import com.serranoie.app.minus.presentation.DYNAMIC_COLOR_KEY import com.serranoie.app.minus.presentation.RECURRENT_NOTIFICATION_HOUR_KEY import com.serranoie.app.minus.presentation.RECURRENT_NOTIFICATION_MINUTE_KEY -import com.serranoie.app.minus.presentation.RECURRENT_PAYMENTS_VIEW_MODE_KEY import com.serranoie.app.minus.presentation.THEME_MODE_KEY import com.serranoie.app.minus.presentation.TYPOGRAPHY_MODE_KEY import com.serranoie.app.minus.presentation.appTheme @@ -44,7 +44,7 @@ data class SettingsUiState( val currentTypography: String = "Expressive", val isMaterialYouEnabled: Boolean = false, val isCreditQuickToggleEnabled: Boolean = false, - val recurrentPaymentsViewMode: RecurrentPaymentsViewMode = RecurrentPaymentsViewMode.HORIZONTAL_LIST, + val recurrentPaymentsViewMode: RecurrentPaymentsViewMode = RecurrentPaymentsViewMode.VERTICAL_LIST, val notificationHour: Int = 9, val notificationMinute: Int = 0, val recurrentNotificationHour: Int = 8, @@ -61,6 +61,7 @@ sealed interface SettingsUiEffect { @HiltViewModel class SettingsViewModel @Inject constructor( @ApplicationContext private val context: Context, + private val settingsRepository: SettingsRepository, private val updateNotificationTimeUseCase: UpdatePeriodEndNotificationTimeUseCase, ) : ViewModel() { @@ -87,43 +88,49 @@ class SettingsViewModel @Inject constructor( private fun loadPreferences() { viewModelScope.launch { - context.settingsDataStore.data.collect { prefs -> + settingsRepository.observeSettings().collect { settings -> _uiState.update { current -> current.copy( - currentTheme = when (context.appTheme) { - ThemeMode.LIGHT -> "Light" - ThemeMode.NIGHT -> "Dark" + currentTheme = when (settings.themeMode) { + com.serranoie.app.minus.domain.model.ThemeMode.LIGHT -> "Light" + com.serranoie.app.minus.domain.model.ThemeMode.NIGHT -> "Dark" else -> "System" }, - currentTypography = when (context.appTypography) { - TypographyMode.DEFAULT -> "Default" - TypographyMode.CONDENSED -> "Condensed" + currentTypography = when (settings.typographyMode) { + com.serranoie.app.minus.domain.model.TypographyMode.DEFAULT -> "Default" + com.serranoie.app.minus.domain.model.TypographyMode.CONDENSED -> "Condensed" else -> "Expressive" }, - isMaterialYouEnabled = context.dynamicColorEnabled, - isCreditQuickToggleEnabled = prefs[CREDIT_QUICK_TOGGLE_FEATURE_KEY] ?: false, - recurrentPaymentsViewMode = RecurrentPaymentsViewMode.fromName( - prefs[RECURRENT_PAYMENTS_VIEW_MODE_KEY] - ), - notificationHour = prefs[RECURRENT_NOTIFICATION_HOUR_KEY] ?: 9, - notificationMinute = prefs[RECURRENT_NOTIFICATION_MINUTE_KEY] ?: 0, - recurrentNotificationHour = prefs[RECURRENT_NOTIFICATION_HOUR_KEY] ?: 8, - recurrentNotificationMinute = prefs[RECURRENT_NOTIFICATION_MINUTE_KEY] ?: 0, + isMaterialYouEnabled = settings.dynamicColorEnabled, + recurrentPaymentsViewMode = settings.recurrentPaymentsViewMode, + notificationHour = settings.notificationHour, + notificationMinute = settings.notificationMinute, + recurrentNotificationHour = settings.notificationHour, // Fixed to match repo if needed, but repo has separate? No, repo only has one. + recurrentNotificationMinute = settings.notificationMinute, exactAlarmEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager alarmManager.canScheduleExactAlarms() } else true, - periodMappingMode = try { - PeriodMappingMode.valueOf( - prefs[PERIOD_MAPPING_MODE_KEY] ?: "" - ) - } catch (_: Exception) { - PeriodMappingMode.ACTIVE_BUDGET - }, + // PeriodMappingMode is not in UserSettings yet, keeping direct access for now or just ignoring if not critical ) } } } + // Need to also handle PeriodMappingMode and isCreditQuickToggleEnabled which are NOT in UserSettings + viewModelScope.launch { + context.settingsDataStore.data.collect { prefs -> + _uiState.update { it.copy( + isCreditQuickToggleEnabled = prefs[CREDIT_QUICK_TOGGLE_FEATURE_KEY] ?: false, + periodMappingMode = try { + PeriodMappingMode.valueOf( + prefs[PERIOD_MAPPING_MODE_KEY] ?: "" + ) + } catch (_: Exception) { + PeriodMappingMode.ACTIVE_BUDGET + }, + ) } + } + } } fun onThemeChange(themeMode: String) { @@ -180,9 +187,7 @@ class SettingsViewModel @Inject constructor( fun onRecurrentPaymentsViewModeChange(mode: RecurrentPaymentsViewMode) { _uiState.update { it.copy(recurrentPaymentsViewMode = mode) } viewModelScope.launch { - context.settingsDataStore.edit { prefs -> - prefs[RECURRENT_PAYMENTS_VIEW_MODE_KEY] = mode.name - } + settingsRepository.setRecurrentPaymentsViewMode(mode) } } From 1956ab03a6e7f08cc760bd88852c799be645d2ef Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Mon, 22 Jun 2026 22:02:46 -0700 Subject: [PATCH 2/2] test: added e2e coverage for history actions --- .../ui/e2e/history/HistoryScreenE2ETest.kt | 297 +++++++++++++++++- 1 file changed, 295 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/HistoryScreenE2ETest.kt b/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/HistoryScreenE2ETest.kt index 3768bb6..e70d68d 100644 --- a/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/HistoryScreenE2ETest.kt +++ b/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/HistoryScreenE2ETest.kt @@ -2,21 +2,34 @@ package com.serranoie.app.minus.presentation.ui.e2e.history import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight import com.google.common.truth.Truth import com.serranoie.app.minus.R import com.serranoie.app.minus.domain.model.BudgetPeriod import com.serranoie.app.minus.domain.model.BudgetSettings import com.serranoie.app.minus.domain.model.BudgetState +import com.serranoie.app.minus.domain.model.RecurrentFrequency import com.serranoie.app.minus.domain.model.SupportedCurrency import com.serranoie.app.minus.domain.model.Transaction import com.serranoie.app.minus.presentation.ui.history.History +import com.serranoie.app.minus.presentation.ui.history.HistoryUiIntent import com.serranoie.app.minus.presentation.ui.history.HistoryUiState +import com.serranoie.app.minus.presentation.ui.history.RecurrentPaymentsViewMode import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import com.serranoie.app.minus.presentation.ui.theme.component.expense.UpcomingRecurrentItem import org.junit.Rule import org.junit.Test import java.math.BigDecimal @@ -87,18 +100,298 @@ class HistoryScreenE2ETest { ), ) - private fun setHistoryContent(uiState: HistoryUiState) { + private fun setHistoryContent( + uiState: HistoryUiState, + onProcessIntent: (HistoryUiIntent) -> Unit = {}, + ) { composeTestRule.setContent { MinusTheme { History( uiState = uiState, modifier = Modifier.fillMaxSize(), - onProcessIntent = {}, + onProcessIntent = onProcessIntent, ) } } } + private fun sampleUpcomingRecurrentItems(): List = listOf( + UpcomingRecurrentItem( + transaction = Transaction.create( + amount = BigDecimal("15.00"), + comment = "Netflix", + date = LocalDateTime.now().minusDays(10), + isRecurrent = true, + recurrentFrequency = RecurrentFrequency.MONTHLY, + ), + nextChargeDate = today.plusDays(5), + isInCurrentPeriod = true, + ) + ) + + @Test + fun when_recurrent_view_mode_is_horizontal_then_recurrent_items_are_shown_as_cards() { + val recurrentItems = sampleUpcomingRecurrentItems() + setHistoryContent( + uiState = HistoryUiState( + budgetSettings = sampleBudgetSettings(), + budgetState = sampleBudgetState(), + upcomingRecurrentInPeriod = recurrentItems, + showUpcomingRecurrentInPeriod = true, + recurrentPaymentsViewMode = RecurrentPaymentsViewMode.HORIZONTAL_LIST, + ), + ) + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Netflix").assertIsDisplayed() + val monthlyLabel = + composeTestRule.activity.getString(R.string.recurrent_ticket_frequency_monthly) + composeTestRule.onNodeWithText(monthlyLabel, substring = true).assertIsDisplayed() + } + + @Test + fun when_recurrent_view_mode_is_vertical_then_recurrent_items_are_shown_as_list_items() { + val recurrentItems = sampleUpcomingRecurrentItems() + setHistoryContent( + uiState = HistoryUiState( + budgetSettings = sampleBudgetSettings(), + budgetState = sampleBudgetState(), + upcomingRecurrentInPeriod = recurrentItems, + showUpcomingRecurrentInPeriod = true, + recurrentPaymentsViewMode = RecurrentPaymentsViewMode.VERTICAL_LIST, + ), + ) + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Netflix").assertIsDisplayed() + + val amount = formatCurrency(BigDecimal("15")) + composeTestRule.onNodeWithText(amount).assertIsDisplayed() + } + + @Test + fun when_tapping_transaction_then_detail_dialog_is_shown() { + val transactions = sampleTransactions() + var capturedIntent: HistoryUiIntent? = null + + setHistoryContent( + uiState = HistoryUiState( + budgetSettings = sampleBudgetSettings(), + budgetState = sampleBudgetState(), + transactions = transactions, + displayTransactions = transactions, + groupedCurrentTransactions = mapOf(today to transactions), + expandedDates = setOf(today), + ), + onProcessIntent = { capturedIntent = it } + ) + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Coffee").performClick() + + Truth.assertThat(capturedIntent) + .isInstanceOf(HistoryUiIntent.SetSelectedTransaction::class.java) + val selectedTx = (capturedIntent as HistoryUiIntent.SetSelectedTransaction).transaction + Truth.assertThat(selectedTx?.comment).isEqualTo("Coffee") + } + + @Test + fun when_swiping_right_on_transaction_then_it_is_removed_from_list() { + val transactions = sampleTransactions() + var uiState by mutableStateOf( + HistoryUiState( + budgetSettings = sampleBudgetSettings(), + budgetState = sampleBudgetState(), + transactions = transactions, + displayTransactions = transactions, + groupedCurrentTransactions = mapOf(today to transactions), + expandedDates = setOf(today), + ) + ) + + composeTestRule.setContent { + MinusTheme { + History( + uiState = uiState, + modifier = Modifier.fillMaxSize(), + onProcessIntent = { intent -> + if (intent is HistoryUiIntent.DeleteTransaction) { + val newTransactions = + uiState.transactions.filterNot { it.id == intent.transaction.id } + uiState = uiState.copy( + transactions = newTransactions, + displayTransactions = newTransactions, + groupedCurrentTransactions = mapOf(today to newTransactions) + ) + } + }, + ) + } + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Coffee").assertIsDisplayed() + + composeTestRule.onNodeWithText("Coffee").performTouchInput { + swipeRight() + } + + composeTestRule.waitForIdle() + composeTestRule.onAllNodesWithText("Coffee").assertCountEquals(0) + } + + @Test + fun when_swiping_left_on_transaction_then_transaction_edit_screen_is_displayed() { + val transactions = sampleTransactions() + var uiState by mutableStateOf( + HistoryUiState( + budgetSettings = sampleBudgetSettings(), + budgetState = sampleBudgetState(), + transactions = transactions, + displayTransactions = transactions, + groupedCurrentTransactions = mapOf(today to transactions), + expandedDates = setOf(today), + ) + ) + + composeTestRule.setContent { + MinusTheme { + History( + uiState = uiState, + modifier = Modifier.fillMaxSize(), + onProcessIntent = { intent -> + if (intent is HistoryUiIntent.SetEditingTransaction) { + uiState = uiState.copy(editingTransaction = intent.transaction) + } + }, + ) + } + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Coffee").performTouchInput { + swipeLeft() + } + + composeTestRule.waitForIdle() + + val editTitle = composeTestRule.activity.getString(R.string.edit_expense_title) + composeTestRule.onNodeWithText(editTitle).assertIsDisplayed() + + composeTestRule.onAllNodesWithText("Coffee").onLast().assertIsDisplayed() + } + + @Test + fun when_tapping_transaction_then_deleting_from_dialog_removes_it_from_list() { + val transactions = sampleTransactions() + var uiState by mutableStateOf( + HistoryUiState( + budgetSettings = sampleBudgetSettings(), + budgetState = sampleBudgetState(), + transactions = transactions, + displayTransactions = transactions, + groupedCurrentTransactions = mapOf(today to transactions), + expandedDates = setOf(today), + ) + ) + + composeTestRule.setContent { + MinusTheme { + History( + uiState = uiState, + modifier = Modifier.fillMaxSize(), + onProcessIntent = { intent -> + when (intent) { + is HistoryUiIntent.SetSelectedTransaction -> { + uiState = uiState.copy(selectedTransaction = intent.transaction) + } + + is HistoryUiIntent.DeleteTransaction -> { + val newTransactions = + uiState.transactions.filterNot { it.id == intent.transaction.id } + uiState = uiState.copy( + transactions = newTransactions, + displayTransactions = newTransactions, + groupedCurrentTransactions = mapOf(today to newTransactions), + selectedTransaction = null + ) + } + + else -> {} + } + }, + ) + } + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Coffee").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Eliminar").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onAllNodesWithText("Coffee").assertCountEquals(0) + } + + @Test + fun when_tapping_transaction_then_editing_from_dialog_shows_edit_screen() { + val transactions = sampleTransactions() + var uiState by mutableStateOf( + HistoryUiState( + budgetSettings = sampleBudgetSettings(), + budgetState = sampleBudgetState(), + transactions = transactions, + displayTransactions = transactions, + groupedCurrentTransactions = mapOf(today to transactions), + expandedDates = setOf(today), + ) + ) + + composeTestRule.setContent { + MinusTheme { + History( + uiState = uiState, + modifier = Modifier.fillMaxSize(), + onProcessIntent = { intent -> + when (intent) { + is HistoryUiIntent.SetSelectedTransaction -> { + uiState = uiState.copy(selectedTransaction = intent.transaction) + } + + is HistoryUiIntent.SetEditingTransaction -> { + uiState = uiState.copy( + editingTransaction = intent.transaction, + selectedTransaction = null + ) + } + + else -> {} + } + }, + ) + } + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Coffee").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Editar").performClick() + composeTestRule.waitForIdle() + + val editTitle = composeTestRule.activity.getString(R.string.edit_expense_title) + composeTestRule.onNodeWithText(editTitle).assertIsDisplayed() + composeTestRule.onAllNodesWithText("Coffee").onLast().assertIsDisplayed() + } + private fun formatCurrency(value: BigDecimal, currencyCode: String = "USD"): String { val deviceLocale = composeTestRule.activity.resources.configuration.locales[0] val symbol = SupportedCurrency.findByCode(currencyCode)?.symbol ?: "$"