From 0fe8fac1f3c9c040a1e65e15a23b16abf4fd92bf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 24 Jul 2026 12:41:36 -0400 Subject: [PATCH] fix(amount-entry): reset entered amount when the selected token changes Switching tokens re-denominates an amount entry, so an amount typed in one token was being silently reinterpreted in another. Mirror the existing region/currency reset behaviour: - AmountEntryDelegate gains an optional `tokenChanges` trigger; its init now resets the keypad on either a preferred-rate change or a token change. Wired into the give-cash and chat amount entries (global selected token). - TippingCoordinator clears the tip modal's selected amount on token change. Pinned-token flows (withdrawal, swap) and the picker-less tip keypad screen are intentionally left untouched. --- .../app/cash/internal/CashScreenViewModel.kt | 1 + .../app/messenger/internal/ChatViewModel.kt | 1 + .../shared/amountentry/AmountEntryDelegate.kt | 26 +++++++++++++------ .../shared/tipping/TippingCoordinator.kt | 15 +++++++++++ 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt index 6b1f02a30..5f0cb92a1 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt @@ -87,6 +87,7 @@ internal class CashScreenViewModel @Inject constructor( loadingState = stateFlow.map { it.generatingBill } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LoadingSuccessState()), maxAmount = maxForGiveFlow, + tokenChanges = tokenCoordinator.observeSelectedTokenMint(), ) private val tokenInitialized = CompletableDeferred() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 8fba08333..10a2281ee 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -281,6 +281,7 @@ internal class ChatViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LoadingSuccessState()), maxAmount = maxAmountFlow, minimumAmount = minAmountFlow, + tokenChanges = tokenCoordinator.observeSelectedTokenMint(), ) } diff --git a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt index f5f4a76e3..cf2a5cd82 100644 --- a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt +++ b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt @@ -8,13 +8,18 @@ import com.getcode.ui.components.text.AmountAnimatedInputUiModel import com.getcode.ui.components.text.NumberInputHelper import com.getcode.view.LoadingSuccessState import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn @@ -29,6 +34,10 @@ class AmountEntryDelegate( loadingState: StateFlow = MutableStateFlow(LoadingSuccessState()), maxAmount: StateFlow = MutableStateFlow(null), minimumAmount: StateFlow = MutableStateFlow(null), + // Emits whenever the selected token changes. Like a region/currency change, switching + // tokens re-denominates the entry, so the typed amount is reset (see init). Defaults to a + // no-op for flows without a token concept. + tokenChanges: Flow<*> = emptyFlow(), ) : AmountEntryController { constructor( exchange: Exchange, @@ -38,7 +47,8 @@ class AmountEntryDelegate( loadingState: StateFlow = MutableStateFlow(LoadingSuccessState()), maxAmount: StateFlow = MutableStateFlow(null), minimumAmount: StateFlow = MutableStateFlow(null), - ) : this(exchange, scope, maxLength, MutableStateFlow(style), loadingState, maxAmount, minimumAmount) + tokenChanges: Flow<*> = emptyFlow(), + ) : this(exchange, scope, maxLength, MutableStateFlow(style), loadingState, maxAmount, minimumAmount, tokenChanges) data class State( val currency: CurrencyHolder = CurrencyHolder(), @@ -106,13 +116,13 @@ class AmountEntryDelegate( init { numberInputHelper.reset() - exchange.observePreferredRate() - .onEach { - numberInputHelper.reset() - _state.update { s -> - s.copy(amountAnimatedModel = AmountAnimatedInputUiModel()) - } - }.launchIn(scope) + // Reset the typed amount whenever the entry is re-denominated: a preferred + // currency/region change (rate) or a selected-token change. `drop(1)` on the token + // stream skips its initial value so an in-flight prefill isn't wiped on construction. + merge( + exchange.observePreferredRate(), + tokenChanges.distinctUntilChanged().drop(1), + ).onEach { reset() }.launchIn(scope) } fun onCurrencyChanged(currency: Currency) { diff --git a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt index 8b376e319..4f7ed58db 100644 --- a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt +++ b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt @@ -39,10 +39,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -113,6 +117,17 @@ class TippingCoordinator @Inject constructor( ) { state, presets -> state.copy(presets = presets) } .stateIn(scope, SharingStarted.WhileSubscribed(5_000), TipSelectionState()) + init { + // Switching the tip token re-denominates the tip, so clear any amount chosen in the modal + // (preset or custom) — mirroring how the amount-entry keypad resets on a token/region + // change. `drop(1)` skips the initial token resolution so startup doesn't clear anything. + tokenCoordinator.observeSelectedTokenMint() + .distinctUntilChanged() + .drop(1) + .onEach { selectAmount(null) } + .launchIn(scope) + } + /** The largest tippable amount (send-limit ∧ balance), surfaced by the amount entry. */ val maxTipAmount: StateFlow get() = tipPaymentDelegate.maxTipAmount