commit ed282145237ec43329a805bfc9849cae4737c270 parent 0416100db68ca75f883c93615e294587c25d0077 Author: Segun Famisa <sfamisa@mozilla.com> Date: Fri, 21 Nov 2025 14:33:27 +0000 Bug 1998196 - Implement state management for the Credit Card Editor r=android-reviewers,boek This patch adds the state management logic for the credit card editor The key changes include: - `CreditCardEditorStore`: A new `UiStore` to manage the state of the editor. - `CreditCardEditorState`: Now includes a default state and is managed by the store. - `CreditCardEditorAction`: A new sealed interface that defines all possible user interactions and events, such as field changes, save/delete actions, and initialization. - `creditCardEditorReducer`: A new pure function that handles state transitions based on dispatched actions. - `CreditCardEditorMiddleware`: A new middleware to handle side effects like database operations (saving, updating, deleting cards), navigation, and validation logic. - `CreditCardEditorEnvironment`: A new data class to provide external dependencies like navigation callbacks to the middleware. - `CalendarDataProvider`: A new interface and its default implementation to supply month and year data for the expiry date fields, decoupling the logic from the UI. - `CreditCardEditorScreen.kt`: The composable is updated to connect to the `CreditCardEditorStore`, observe state changes, and dispatch actions based on user interactions. Differential Revision: https://phabricator.services.mozilla.com/D271088 Diffstat:
7 files changed, 534 insertions(+), 1 deletion(-)
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CalendarDataProvider.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CalendarDataProvider.kt @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.creditcards.ui + +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +/** + * Provider for calendar data used in the credit card editor + */ +interface CalendarDataProvider { + + /** + * Returns a list of months. + * + * @return a list of months + */ + fun months(): List<String> + + /** + * Returns a list of years supported by the credit expiry card year field. + * + * @return a list of years + */ + fun years(): List<String> + + /** + * Returns a list of years supported by the credit expiry card year field. + * + * @param startYear The start year to use for the list of years. + * @return a list of years + */ + fun years(startYear: Long): List<String> +} + +/** + * Default implementation of [CalendarDataProvider] using [Calendar] + * + * @param dateFormat The [SimpleDateFormat] to use for formatting the dates. + */ +class DefaultCalendarDataProvider( + private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMMM (MM)", Locale.getDefault()), +) : CalendarDataProvider { + + override fun months(): List<String> { + val calendar = Calendar.getInstance() + return buildList { + calendar.set(Calendar.DAY_OF_MONTH, 1) + + for (month in 0..NUMBER_OF_MONTHS) { + calendar.set(Calendar.MONTH, month) + add(dateFormat.format(calendar.time)) + } + } + } + + override fun years(): List<String> { + val calendar = Calendar.getInstance() + val startYear = calendar.get(Calendar.YEAR) + return years(startYear = startYear.toLong()) + } + + override fun years(startYear: Long): List<String> { + val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW + return buildList { + for (year in startYear..endYear + NUMBER_OF_YEARS_TO_SHOW) { + add(year.toString()) + } + } + } +} + +/** + * Number of months in a year (0-indexed). + */ +private const val NUMBER_OF_MONTHS = 11 + +/** + * Number of years to show in the credit card expiry year field. + */ +private const val NUMBER_OF_YEARS_TO_SHOW = 10 diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorAction.kt @@ -0,0 +1,109 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.creditcards.ui + +import mozilla.components.concept.storage.CreditCard +import mozilla.components.lib.state.Action + +/** + * Represents the various actions that can be triggered from the credit card editor screen, + * such as saving a credit card or handling back navigation. + */ +sealed interface CreditCardEditorAction : Action { + + /** + * A group of actions that are dispatched when the credit card editor is initialized. + */ + sealed interface Initialization : CreditCardEditorAction { + /** + * Initialize the credit card editor with the provided [CreditCard]. + * + * @property creditCard Optional [CreditCard] to be initialized in the editor. When + * this property is null, a new credit card will be created. + */ + data class InitStarted(val creditCard: CreditCard?) : Initialization + + /** + * Signal that the initialization process has completed. + * + * @property state The final state of the credit card editor after initialization. + */ + data class InitCompleted(val state: CreditCardEditorState) : Initialization + } + + /** + * An action that is dispatched when the environment has been rehydrated from a configuration change. + * + * @property environment The [CreditCardEditorEnvironment] associated with the action. + */ + data class EnvironmentRehydrated(val environment: CreditCardEditorEnvironment) : + CreditCardEditorAction + + /** + * An action that signals the intention to navigate back from the current screen. + */ + data object NavigateBack : CreditCardEditorAction + + /** + * An action that represents the user's request to save the entered credit card details. + */ + data object Save : CreditCardEditorAction + + /** + * An action triggered when the user decides to discard the changes and exit the editor. + */ + data object Cancel : CreditCardEditorAction + + /** + * An action triggered when the user wants to delete the current credit card. + */ + data object DeleteClicked : CreditCardEditorAction + + /** + * A group of actions for handling the delete confirmation dialog. + */ + sealed interface DeleteDialogAction : CreditCardEditorAction { + + /** + * An action that signals the user's intention to cancel the delete operation. + */ + data object Cancel : DeleteDialogAction + + /** + * An action that signals the user's confirmation to delete the credit card. + */ + data object Confirm : DeleteDialogAction + } + + /** + * A group of actions that are dispatched whenever a field in the credit card form is modified. + */ + sealed interface FieldChanged : CreditCardEditorAction { + + /** + * Dispatched when the credit card number is changed by the user. + * @property cardNumber The updated credit card number string. + */ + data class CardNumberChanged(val cardNumber: String) : FieldChanged + + /** + * Dispatched when the name on the credit card is changed. + * @property nameOnCard The updated name on the card. + */ + data class NameOnCardChanged(val nameOnCard: String) : FieldChanged + + /** + * Dispatched when the expiration month of the credit card is selected. + * @property index The index of the selected month. + */ + data class MonthSelected(val index: Int) : FieldChanged + + /** + * Dispatched when the expiration year of the credit card is selected. + * @property index The index of the selected year. + */ + data class YearSelected(val index: Int) : FieldChanged + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorEnvironment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorEnvironment.kt @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.creditcards.ui + +/** + * Groups together all of the [CreditCardEditorStore] external dependencies. + * + * @property navigateBack used to navigate back. + */ +data class CreditCardEditorEnvironment( + val navigateBack: () -> Unit, +) { + + internal companion object { + val Default = CreditCardEditorEnvironment(navigateBack = {}) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorMiddleware.kt @@ -0,0 +1,208 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.creditcards.ui + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.CreditCardsAddressesStorage +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.support.utils.creditCardIIN +import org.mozilla.fenix.settings.creditcards.last4Digits +import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorAction.DeleteDialogAction + +/** + * Middleware for the credit card editor feature + * + * @param environment The [CreditCardEditorEnvironment] to use for external lifecycle-sensitive things. + * @param storage The [CreditCardsAddressesStorage] to use for adding and updating credit cards. + * @param calendarDataProvider The [CalendarDataProvider] to use for providing calendar data. + * @param coroutineScope The [CoroutineScope] to use for launching coroutines. + * @param ioDispatcher The [CoroutineDispatcher] to use for executing IO operations. + * @param mainDispatcher The [CoroutineDispatcher] to use for executing main-thread operations. + */ +internal class CreditCardEditorMiddleware( + private var environment: CreditCardEditorEnvironment? = null, + private val storage: CreditCardsAddressesStorage, + private val calendarDataProvider: CalendarDataProvider = DefaultCalendarDataProvider(), + private val coroutineScope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, +) : Middleware<CreditCardEditorState, CreditCardEditorAction> { + + override fun invoke( + context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, + next: (CreditCardEditorAction) -> Unit, + action: CreditCardEditorAction, + ) { + next(action) + when (action) { + is CreditCardEditorAction.Initialization -> action.handleInitAction(context) + is CreditCardEditorAction.EnvironmentRehydrated -> environment = action.environment + is DeleteDialogAction -> action.handleDeleteDialog(context) + + is CreditCardEditorAction.Save -> handleSaveAction(context) + is CreditCardEditorAction.NavigateBack, + is CreditCardEditorAction.Cancel, + -> navigateBack() + + else -> Unit + } + } + + private fun DeleteDialogAction.handleDeleteDialog( + context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, + ) { + when (this) { + DeleteDialogAction.Confirm -> { + coroutineScope.launch(ioDispatcher) { + storage.deleteCreditCard(context.state.guid) + + withContext(mainDispatcher) { + navigateBack() + } + } + } + + else -> Unit + } + } + + private fun navigateBack() { + environment?.navigateBack() + } + + private fun handleSaveAction( + context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, + ) { + val state = context.state + + if (!state.showCardNumberError && !state.showNameOnCardError) { + addOrUpdateCard(state) + } + } + + private fun addOrUpdateCard(state: CreditCardEditorState) { + if (state.inEditMode) { + updateCreditCard(state) + } else { + addCreditCard(state) + } + } + + private fun updateCreditCard(state: CreditCardEditorState) { + coroutineScope.launch(ioDispatcher) { + val fields = UpdatableCreditCardFields( + billingName = state.nameOnCard, + cardNumber = CreditCardNumber.Plaintext(state.cardNumber), + cardNumberLast4 = state.cardNumber.last4Digits(), + expiryMonth = state.selectedExpiryMonthIndex + 1L, + expiryYear = state.expiryYears[state.selectedExpiryYearIndex].toLong(), + cardType = state.cardNumber.creditCardIIN()?.creditCardIssuerNetwork?.name ?: "", + ) + + storage.updateCreditCard(state.guid, fields) + + withContext(mainDispatcher) { + navigateBack() + } + } + } + + private fun addCreditCard(state: CreditCardEditorState) { + coroutineScope.launch(ioDispatcher) { + val fields = NewCreditCardFields( + billingName = state.nameOnCard, + plaintextCardNumber = CreditCardNumber.Plaintext(state.cardNumber), + cardNumberLast4 = state.cardNumber.last4Digits(), + expiryMonth = state.selectedExpiryMonthIndex + 1L, + expiryYear = state.expiryYears[state.selectedExpiryYearIndex].toLong(), + cardType = state.cardNumber.creditCardIIN()?.creditCardIssuerNetwork?.name ?: "", + ) + + storage.addCreditCard(fields) + + withContext(mainDispatcher) { + navigateBack() + } + } + } + + private fun CreditCardEditorAction.Initialization.handleInitAction( + context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, + ) { + when (this) { + is CreditCardEditorAction.Initialization.InitStarted -> { + if (creditCard != null) { + initializeFromCard(context, creditCard) + } else { + initializeFromScratch(context) + } + } + + else -> Unit + } + } + + private fun initializeFromScratch( + context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, + ) { + val state = context.state + context.store.dispatch( + CreditCardEditorAction.Initialization.InitCompleted( + state = state.copy( + expiryMonths = calendarDataProvider.months(), + selectedExpiryMonthIndex = 0, + expiryYears = calendarDataProvider.years(), + selectedExpiryYearIndex = 0, + inEditMode = false, + showDeleteDialog = false, + ), + ), + ) + } + + private fun initializeFromCard( + context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, + creditCard: CreditCard, + ) { + coroutineScope.launch(ioDispatcher) { + val state = context.state + val crypto = storage.getCreditCardCrypto() + + val plainTextCardNumber = crypto.decrypt( + key = crypto.getOrGenerateKey(), + encryptedCardNumber = creditCard.encryptedCardNumber, + ) + + val years = calendarDataProvider.years(creditCard.expiryYear) + + context.store.dispatch( + CreditCardEditorAction.Initialization.InitCompleted( + state = state.copy( + guid = creditCard.guid, + nameOnCard = creditCard.billingName, + cardNumber = plainTextCardNumber?.number ?: "", + expiryMonths = calendarDataProvider.months(), + selectedExpiryMonthIndex = creditCard.expiryMonth.toInt() - 1, + expiryYears = years, + selectedExpiryYearIndex = years.indexOfFirst { year -> + year == creditCard.expiryYear.toString() + }, + inEditMode = true, + showDeleteDialog = false, + ), + ), + ) + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorReducer.kt @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.creditcards.ui + +import org.mozilla.fenix.settings.creditcards.validateCreditCardNumber + +/** + * Reducer for the [CreditCardEditorStore]. + * + * @param state The current state of the store. + * @param action The action to be handled. + * + * @return The new state of the store. + */ +internal fun creditCardEditorReducer( + state: CreditCardEditorState, + action: CreditCardEditorAction, +): CreditCardEditorState { + return when (action) { + is CreditCardEditorAction.Initialization -> state.handleInitialization(action) + is CreditCardEditorAction.FieldChanged -> state.handleFieldChange(action) + is CreditCardEditorAction.DeleteClicked -> state.copy(showDeleteDialog = true) + is CreditCardEditorAction.DeleteDialogAction -> state.copy(showDeleteDialog = false) + is CreditCardEditorAction.Save -> state.validateInput() + else -> state + } +} + +private fun CreditCardEditorState.validateInput(): CreditCardEditorState { + val validCardNumber = cardNumber.validateCreditCardNumber() + val validNameOnCard = nameOnCard.isNotBlank() + + return copy(showNameOnCardError = !validNameOnCard, showCardNumberError = !validCardNumber) +} + +private fun CreditCardEditorState.handleInitialization( + action: CreditCardEditorAction.Initialization, +): CreditCardEditorState { + return when (action) { + is CreditCardEditorAction.Initialization.InitCompleted -> copy( + guid = action.state.guid, + cardNumber = action.state.cardNumber, + showCardNumberError = action.state.showCardNumberError, + nameOnCard = action.state.nameOnCard, + showNameOnCardError = action.state.showNameOnCardError, + expiryMonths = action.state.expiryMonths, + selectedExpiryMonthIndex = action.state.selectedExpiryMonthIndex, + expiryYears = action.state.expiryYears, + selectedExpiryYearIndex = action.state.selectedExpiryYearIndex, + inEditMode = action.state.inEditMode, + showDeleteDialog = action.state.showDeleteDialog, + ) + + else -> this + } +} + +private fun CreditCardEditorState.handleFieldChange( + action: CreditCardEditorAction.FieldChanged, +): CreditCardEditorState { + return when (action) { + is CreditCardEditorAction.FieldChanged.CardNumberChanged -> copy( + cardNumber = action.cardNumber, + showCardNumberError = false, + ) + + is CreditCardEditorAction.FieldChanged.MonthSelected -> copy(selectedExpiryMonthIndex = action.index) + is CreditCardEditorAction.FieldChanged.NameOnCardChanged -> copy( + nameOnCard = action.nameOnCard, + showNameOnCardError = false, + ) + + is CreditCardEditorAction.FieldChanged.YearSelected -> copy(selectedExpiryYearIndex = action.index) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorState.kt @@ -33,4 +33,18 @@ data class CreditCardEditorState( val selectedExpiryYearIndex: Int, val inEditMode: Boolean, val showDeleteDialog: Boolean = false, -) : State +) : State { + + companion object { + val Default = CreditCardEditorState( + guid = "", + cardNumber = "", + nameOnCard = "", + inEditMode = false, + expiryMonths = listOf(), + selectedExpiryMonthIndex = 0, + expiryYears = listOf(), + selectedExpiryYearIndex = 0, + ) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStore.kt @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.creditcards.ui + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.Reducer +import mozilla.components.lib.state.Store + +/** + * A Store for handling [CreditCardEditorState] and dispatching [CreditCardEditorAction]. + */ +class CreditCardEditorStore( + initialState: CreditCardEditorState, + reducer: Reducer<CreditCardEditorState, CreditCardEditorAction> = ::creditCardEditorReducer, + middleware: List<Middleware<CreditCardEditorState, CreditCardEditorAction>> = listOf(), +) : Store<CreditCardEditorState, CreditCardEditorAction>( + initialState = initialState, + reducer = reducer, + middleware = middleware, +)