From d91346274f0ad41961f710023992ebad0bbde804 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 2 Jun 2026 09:35:13 -0400 Subject: [PATCH] feat(direct-send): add AmountEntryViewModel, AmountEntryScreen, and ConfirmationStyle Reusable Hilt ViewModel for keypad amount entry with limits, balance validation, and currency formatting. Composable screen switches between Button and Slide confirmation styles via ConfirmationStyle enum. Signed-off-by: Brandon McAnsh --- .../flipcash/app/core/ui/ConfirmationStyle.kt | 9 + .../features/direct-send/build.gradle.kts | 2 + .../internal/AmountEntryViewModel.kt | 248 ++++++++++++++++++ .../internal/screens/AmountEntryScreen.kt | 146 +++++++++++ 4 files changed, 405 insertions(+) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/ConfirmationStyle.kt create mode 100644 apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/AmountEntryViewModel.kt create mode 100644 apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/AmountEntryScreen.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/ConfirmationStyle.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/ConfirmationStyle.kt new file mode 100644 index 000000000..5b428e955 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/ConfirmationStyle.kt @@ -0,0 +1,9 @@ +package com.flipcash.app.core.ui + +/** Determines the confirmation widget shown on amount entry screens. */ +enum class ConfirmationStyle { + /** Standard tap-to-confirm button (CodeButton). */ + Button, + /** Swipe-to-confirm slider (SlideToConfirm). */ + Slide, +} diff --git a/apps/flipcash/features/direct-send/build.gradle.kts b/apps/flipcash/features/direct-send/build.gradle.kts index 569b6273a..ed5374c86 100644 --- a/apps/flipcash/features/direct-send/build.gradle.kts +++ b/apps/flipcash/features/direct-send/build.gradle.kts @@ -24,4 +24,6 @@ dependencies { implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:permissions")) implementation(project(":apps:flipcash:shared:contacts")) + implementation(project(":services:opencode")) + implementation(project(":services:flipcash")) } diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/AmountEntryViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/AmountEntryViewModel.kt new file mode 100644 index 000000000..db0624803 --- /dev/null +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/AmountEntryViewModel.kt @@ -0,0 +1,248 @@ +package com.flipcash.app.directsend.internal + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.ui.CurrencyHolder +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.directsend.R +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Limits +import com.getcode.opencode.model.financial.SendLimit +import com.getcode.opencode.model.financial.Token +import com.getcode.ui.components.text.AmountAnimatedInputUiModel +import com.getcode.ui.components.text.NumberInputHelper +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject +import kotlin.math.min + +/** + * Reusable ViewModel for amount entry with keypad input, currency selection, + * and limit validation. Emits [Event.AmountConfirmed] when the user confirms + * a valid amount. The caller is responsible for executing the transaction. + */ +@HiltViewModel +internal class AmountEntryViewModel @Inject constructor( + private val resources: ResourceHelper, + private val exchange: Exchange, + private val transactionController: TransactionController, + private val tokenCoordinator: TokenCoordinator, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + private val numberInputHelper = NumberInputHelper() + + data class State( + val token: Token? = null, + val currencyModel: CurrencyHolder = CurrencyHolder(), + val amountAnimatedModel: AmountAnimatedInputUiModel = AmountAnimatedInputUiModel(), + val limits: Limits? = null, + val maxSend: Pair? = null, + ) { + val canSend: Boolean + get() = amountAnimatedModel.amountData.amount > 0.0 + + val isError: Boolean + get() { + if (amountAnimatedModel.amountData.isEmpty()) return false + if (maxSend != null) { + val enteredAmount = Fiat( + fiat = amountAnimatedModel.amountData.amount, + currencyCode = maxSend.second + ) + val limit = Fiat(maxSend.first, maxSend.second) + if (enteredAmount.valueLessThanOrEqualTo(limit)) { + return false + } + } + + return true + } + + val maxSendFormatted: String + get() = maxSend?.let { Fiat(it.first, it.second).formatted() }.orEmpty() + } + + sealed interface Event { + data class TokenUpdated(val token: Token) : Event + data class CurrencyChanged(val currency: CurrencyHolder) : Event + + data class OnNumberPressed(val number: Int) : Event + data object OnDecimalPressed : Event + data object OnBackspace : Event + data class OnEnteredNumberChanged(val backspace: Boolean = false) : Event + data class OnAmountChanged(val model: AmountAnimatedInputUiModel) : Event + + data class LimitsChanged(val limits: Limits?) : Event + data class MaxSendDetermined(val max: Double, val currencyCode: CurrencyCode) : Event + + data object OnConfirmRequested : Event + data class AmountConfirmed(val amount: Fiat, val token: Token) : Event + } + + init { + numberInputHelper.reset() + + tokenCoordinator.observeSelectedTokenMint() + .flatMapLatest { mint -> + tokenCoordinator.tokenBalances.map { tokens -> + tokens.find { it.token.address == mint } + } + } + .filterNotNull() + .onEach { tokenWithBalance -> + dispatchEvent(Event.TokenUpdated(tokenWithBalance.token)) + }.launchIn(viewModelScope) + + exchange.observePreferredRate() + .onEach { rate -> + val currency = exchange.getCurrency(rate.currency.name) + if (currency != null) { + dispatchEvent(Event.CurrencyChanged(CurrencyHolder(currency))) + } + }.launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + numberInputHelper.fractionUnits = event.currency.fractionUnits + }.launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + numberInputHelper.fractionUnits = + stateFlow.value.currencyModel.fractionUnits + numberInputHelper.maxLength = 10 + numberInputHelper.onNumber(event.number) + dispatchEvent(Event.OnEnteredNumberChanged()) + }.launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + numberInputHelper.onDot() + dispatchEvent(Event.OnEnteredNumberChanged()) + }.launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + numberInputHelper.onBackspace() + dispatchEvent(Event.OnEnteredNumberChanged(backspace = true)) + }.launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val current = stateFlow.value.amountAnimatedModel + val amount = + numberInputHelper.getFormattedStringForAnimation(includeCommas = true) + + val updated = current.copy( + amountDataLast = current.amountData, + amountData = amount, + lastPressedBackspace = event.backspace, + ) + dispatchEvent(Event.OnAmountChanged(updated)) + }.launchIn(viewModelScope) + + transactionController.limits + .onEach { dispatchEvent(Event.LimitsChanged(it)) } + .launchIn(viewModelScope) + + combine( + transactionController.limits, + tokenCoordinator.observeSelectedTokenMint() + .flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) }, + exchange.observePreferredRate(), + ) { limits, balance, rate -> + val balanceInLocal = balance.convertingTo(rate) + val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero + val max = min(sendLimit.nextTransaction, balanceInLocal.toDouble()) + Event.MaxSendDetermined(max, rate.currency) + }.onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { onConfirmRequested() } + .launchIn(viewModelScope) + } + + private fun checkBalanceLimit(): Boolean { + val amount = stateFlow.value.amountAnimatedModel.amountData.amount + val max = stateFlow.value.maxSend ?: return false + val entered = Fiat(amount, max.second) + val balance = tokenCoordinator.balanceForToken(stateFlow.value.token ?: return false) + val balanceInLocal = balance.convertingTo(exchange.preferredRate) + val isOverBalance = entered.valueGreaterThan(balanceInLocal) + if (isOverBalance) { + BottomBarManager.showAlert( + resources.getString(R.string.error_title_insufficientFunds), + resources.getString(R.string.error_description_insufficientFunds), + ) + } + return isOverBalance + } + + private fun checkSendLimit(): Boolean { + val amount = stateFlow.value.amountAnimatedModel.amountData.amount + val currency = stateFlow.value.currencyModel + val sendLimit = + currency.code?.let { stateFlow.value.limits?.sendLimitFor(it) } ?: SendLimit.Zero + val isOverLimit = amount > sendLimit.nextTransaction + if (isOverLimit) { + BottomBarManager.showAlert( + resources.getString(R.string.error_title_sendLimitReached), + resources.getString(R.string.error_description_sendLimitReached), + ) + } + return isOverLimit + } + + private fun onConfirmRequested() { + if (checkBalanceLimit() || checkSendLimit()) return + + val enteredAmount = numberInputHelper.amount + if (enteredAmount <= 0) return + + val token = stateFlow.value.token ?: return + val rate = exchange.preferredRate + dispatchEvent(Event.AmountConfirmed(Fiat(enteredAmount, rate.currency), token)) + } + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.TokenUpdated -> { state -> + state.copy(token = event.token) + } + is Event.CurrencyChanged -> { state -> + state.copy(currencyModel = event.currency) + } + is Event.OnAmountChanged -> { state -> + state.copy(amountAnimatedModel = event.model) + } + is Event.LimitsChanged -> { state -> + state.copy(limits = event.limits) + } + is Event.MaxSendDetermined -> { state -> + state.copy(maxSend = event.max to event.currencyCode) + } + is Event.OnNumberPressed, + is Event.OnDecimalPressed, + is Event.OnBackspace, + is Event.OnEnteredNumberChanged, + is Event.OnConfirmRequested, + is Event.AmountConfirmed -> { state -> state } + } + } + } +} diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/AmountEntryScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/AmountEntryScreen.kt new file mode 100644 index 000000000..3cecb8160 --- /dev/null +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/AmountEntryScreen.kt @@ -0,0 +1,146 @@ +package com.flipcash.app.directsend.internal.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.tokens.TokenPurpose +import com.flipcash.app.core.ui.AmountWithKeypad +import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.app.core.ui.TokenSelectionPill +import com.flipcash.app.directsend.internal.AmountEntryViewModel +import com.flipcash.features.directsend.R +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.components.SlideToConfirm +import com.getcode.ui.theme.CodeButton +import com.getcode.view.LoadingSuccessState +import kotlinx.coroutines.flow.filterIsInstance + +internal sealed interface AmountEntryResult { + data object Cancelled : AmountEntryResult + data class Confirmed(val amount: Fiat, val token: Token) : AmountEntryResult +} + +@Composable +internal fun AmountEntryScreen( + title: @Composable (Token?) -> Unit, + canChangeCurrency: Boolean = false, + navigator: CodeNavigator = LocalCodeNavigator.current, + confirmationStyle: ConfirmationStyle = ConfirmationStyle.Button, + confirmationState: LoadingSuccessState = LoadingSuccessState(), + onResult: (AmountEntryResult) -> Unit, + leftContents: @Composable () -> Unit = { + AppBarDefaults.UpNavigation { onResult(AmountEntryResult.Cancelled) } + }, + rightContents: @Composable () -> Unit = { } +) { + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.eventFlow + .filterIsInstance() + .collect { event -> onResult(AmountEntryResult.Confirmed(event.amount, event.token)) } + } + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + isInModal = true, + title = { title(state.token) }, + leftIcon = leftContents, + rightContents = rightContents + ) + + AmountWithKeypad( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + amountAnimatedModel = state.amountAnimatedModel, + currencyFlag = state.currencyModel.selected?.resId, + prefix = state.currencyModel.selected?.symbol.orEmpty(), + placeholder = "0", + decimalPlaces = state.currencyModel.fractionUnits, + isError = state.isError, + isClickable = canChangeCurrency, + onAmountClicked = { + navigator.push(AppRoute.Main.RegionSelection) + }, + hint = when { + state.maxSendFormatted.isEmpty() -> "" + state.isError -> stringResource( + R.string.subtitle_sendHintLimitExceeded, + state.maxSendFormatted + ) + else -> stringResource(R.string.subtitle_sendHint, state.maxSendFormatted) + }, + onNumberPressed = { + viewModel.dispatchEvent(AmountEntryViewModel.Event.OnNumberPressed(it)) + }, + onBackspace = { + viewModel.dispatchEvent(AmountEntryViewModel.Event.OnBackspace) + }, + onDecimal = { + viewModel.dispatchEvent(AmountEntryViewModel.Event.OnDecimalPressed) + }, + ) + + Box(modifier = Modifier.fillMaxWidth()) { + val enabled = state.canSend && confirmationState.isIdle + when (confirmationStyle) { + ConfirmationStyle.Button -> { + CodeButton( + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .navigationBarsPadding(), + buttonState = ButtonState.Filled, + isLoading = confirmationState.loading, + isSuccess = confirmationState.success, + text = stringResource(R.string.action_send), + ) { + viewModel.dispatchEvent(AmountEntryViewModel.Event.OnConfirmRequested) + } + } + ConfirmationStyle.Slide -> { + SlideToConfirm( + enabled = enabled, + onConfirm = { + viewModel.dispatchEvent(AmountEntryViewModel.Event.OnConfirmRequested) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .navigationBarsPadding(), + isLoading = confirmationState.loading, + isSuccess = confirmationState.success, + label = stringResource(R.string.action_swipeToSend), + ) + } + } + } + } +}