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), + ) + } + } + } + } +}