|
| 1 | +package com.flipcash.app.directsend.internal |
| 2 | + |
| 3 | +import androidx.lifecycle.viewModelScope |
| 4 | +import com.flipcash.app.core.ui.CurrencyHolder |
| 5 | +import com.flipcash.app.tokens.TokenCoordinator |
| 6 | +import com.flipcash.features.directsend.R |
| 7 | +import com.getcode.manager.BottomBarManager |
| 8 | +import com.getcode.opencode.controllers.TransactionController |
| 9 | +import com.getcode.opencode.exchange.Exchange |
| 10 | +import com.getcode.opencode.model.financial.CurrencyCode |
| 11 | +import com.getcode.opencode.model.financial.Fiat |
| 12 | +import com.getcode.opencode.model.financial.Limits |
| 13 | +import com.getcode.opencode.model.financial.SendLimit |
| 14 | +import com.getcode.opencode.model.financial.Token |
| 15 | +import com.getcode.ui.components.text.AmountAnimatedInputUiModel |
| 16 | +import com.getcode.ui.components.text.NumberInputHelper |
| 17 | +import com.getcode.util.resources.ResourceHelper |
| 18 | +import com.getcode.view.BaseViewModel |
| 19 | +import dagger.hilt.android.lifecycle.HiltViewModel |
| 20 | +import kotlinx.coroutines.flow.combine |
| 21 | +import kotlinx.coroutines.flow.filterIsInstance |
| 22 | +import kotlinx.coroutines.flow.filterNotNull |
| 23 | +import kotlinx.coroutines.flow.flatMapLatest |
| 24 | +import kotlinx.coroutines.flow.launchIn |
| 25 | +import kotlinx.coroutines.flow.map |
| 26 | +import kotlinx.coroutines.flow.onEach |
| 27 | +import javax.inject.Inject |
| 28 | +import kotlin.math.min |
| 29 | + |
| 30 | +/** |
| 31 | + * Reusable ViewModel for amount entry with keypad input, currency selection, |
| 32 | + * and limit validation. Emits [Event.AmountConfirmed] when the user confirms |
| 33 | + * a valid amount. The caller is responsible for executing the transaction. |
| 34 | + */ |
| 35 | +@HiltViewModel |
| 36 | +internal class AmountEntryViewModel @Inject constructor( |
| 37 | + private val resources: ResourceHelper, |
| 38 | + private val exchange: Exchange, |
| 39 | + private val transactionController: TransactionController, |
| 40 | + private val tokenCoordinator: TokenCoordinator, |
| 41 | +) : BaseViewModel<AmountEntryViewModel.State, AmountEntryViewModel.Event>( |
| 42 | + initialState = State(), |
| 43 | + updateStateForEvent = updateStateForEvent, |
| 44 | +) { |
| 45 | + private val numberInputHelper = NumberInputHelper() |
| 46 | + |
| 47 | + data class State( |
| 48 | + val token: Token? = null, |
| 49 | + val currencyModel: CurrencyHolder = CurrencyHolder(), |
| 50 | + val amountAnimatedModel: AmountAnimatedInputUiModel = AmountAnimatedInputUiModel(), |
| 51 | + val limits: Limits? = null, |
| 52 | + val maxSend: Pair<Double, CurrencyCode>? = null, |
| 53 | + ) { |
| 54 | + val canSend: Boolean |
| 55 | + get() = amountAnimatedModel.amountData.amount > 0.0 |
| 56 | + |
| 57 | + val isError: Boolean |
| 58 | + get() { |
| 59 | + if (amountAnimatedModel.amountData.isEmpty()) return false |
| 60 | + if (maxSend != null) { |
| 61 | + val enteredAmount = Fiat( |
| 62 | + fiat = amountAnimatedModel.amountData.amount, |
| 63 | + currencyCode = maxSend.second |
| 64 | + ) |
| 65 | + val limit = Fiat(maxSend.first, maxSend.second) |
| 66 | + if (enteredAmount.valueLessThanOrEqualTo(limit)) { |
| 67 | + return false |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + return true |
| 72 | + } |
| 73 | + |
| 74 | + val maxSendFormatted: String |
| 75 | + get() = maxSend?.let { Fiat(it.first, it.second).formatted() }.orEmpty() |
| 76 | + } |
| 77 | + |
| 78 | + sealed interface Event { |
| 79 | + data class TokenUpdated(val token: Token) : Event |
| 80 | + data class CurrencyChanged(val currency: CurrencyHolder) : Event |
| 81 | + |
| 82 | + data class OnNumberPressed(val number: Int) : Event |
| 83 | + data object OnDecimalPressed : Event |
| 84 | + data object OnBackspace : Event |
| 85 | + data class OnEnteredNumberChanged(val backspace: Boolean = false) : Event |
| 86 | + data class OnAmountChanged(val model: AmountAnimatedInputUiModel) : Event |
| 87 | + |
| 88 | + data class LimitsChanged(val limits: Limits?) : Event |
| 89 | + data class MaxSendDetermined(val max: Double, val currencyCode: CurrencyCode) : Event |
| 90 | + |
| 91 | + data object OnConfirmRequested : Event |
| 92 | + data class AmountConfirmed(val amount: Fiat, val token: Token) : Event |
| 93 | + } |
| 94 | + |
| 95 | + init { |
| 96 | + numberInputHelper.reset() |
| 97 | + |
| 98 | + tokenCoordinator.observeSelectedTokenMint() |
| 99 | + .flatMapLatest { mint -> |
| 100 | + tokenCoordinator.tokenBalances.map { tokens -> |
| 101 | + tokens.find { it.token.address == mint } |
| 102 | + } |
| 103 | + } |
| 104 | + .filterNotNull() |
| 105 | + .onEach { tokenWithBalance -> |
| 106 | + dispatchEvent(Event.TokenUpdated(tokenWithBalance.token)) |
| 107 | + }.launchIn(viewModelScope) |
| 108 | + |
| 109 | + exchange.observePreferredRate() |
| 110 | + .onEach { rate -> |
| 111 | + val currency = exchange.getCurrency(rate.currency.name) |
| 112 | + if (currency != null) { |
| 113 | + dispatchEvent(Event.CurrencyChanged(CurrencyHolder(currency))) |
| 114 | + } |
| 115 | + }.launchIn(viewModelScope) |
| 116 | + |
| 117 | + eventFlow.filterIsInstance<Event.CurrencyChanged>() |
| 118 | + .onEach { event -> |
| 119 | + numberInputHelper.fractionUnits = event.currency.fractionUnits |
| 120 | + }.launchIn(viewModelScope) |
| 121 | + |
| 122 | + eventFlow.filterIsInstance<Event.OnNumberPressed>() |
| 123 | + .onEach { event -> |
| 124 | + numberInputHelper.fractionUnits = |
| 125 | + stateFlow.value.currencyModel.fractionUnits |
| 126 | + numberInputHelper.maxLength = 10 |
| 127 | + numberInputHelper.onNumber(event.number) |
| 128 | + dispatchEvent(Event.OnEnteredNumberChanged()) |
| 129 | + }.launchIn(viewModelScope) |
| 130 | + |
| 131 | + eventFlow.filterIsInstance<Event.OnDecimalPressed>() |
| 132 | + .onEach { |
| 133 | + numberInputHelper.onDot() |
| 134 | + dispatchEvent(Event.OnEnteredNumberChanged()) |
| 135 | + }.launchIn(viewModelScope) |
| 136 | + |
| 137 | + eventFlow.filterIsInstance<Event.OnBackspace>() |
| 138 | + .onEach { |
| 139 | + numberInputHelper.onBackspace() |
| 140 | + dispatchEvent(Event.OnEnteredNumberChanged(backspace = true)) |
| 141 | + }.launchIn(viewModelScope) |
| 142 | + |
| 143 | + eventFlow.filterIsInstance<Event.OnEnteredNumberChanged>() |
| 144 | + .onEach { event -> |
| 145 | + val current = stateFlow.value.amountAnimatedModel |
| 146 | + val amount = |
| 147 | + numberInputHelper.getFormattedStringForAnimation(includeCommas = true) |
| 148 | + |
| 149 | + val updated = current.copy( |
| 150 | + amountDataLast = current.amountData, |
| 151 | + amountData = amount, |
| 152 | + lastPressedBackspace = event.backspace, |
| 153 | + ) |
| 154 | + dispatchEvent(Event.OnAmountChanged(updated)) |
| 155 | + }.launchIn(viewModelScope) |
| 156 | + |
| 157 | + transactionController.limits |
| 158 | + .onEach { dispatchEvent(Event.LimitsChanged(it)) } |
| 159 | + .launchIn(viewModelScope) |
| 160 | + |
| 161 | + combine( |
| 162 | + transactionController.limits, |
| 163 | + tokenCoordinator.observeSelectedTokenMint() |
| 164 | + .flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) }, |
| 165 | + exchange.observePreferredRate(), |
| 166 | + ) { limits, balance, rate -> |
| 167 | + val balanceInLocal = balance.convertingTo(rate) |
| 168 | + val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero |
| 169 | + val max = min(sendLimit.nextTransaction, balanceInLocal.toDouble()) |
| 170 | + Event.MaxSendDetermined(max, rate.currency) |
| 171 | + }.onEach { dispatchEvent(it) } |
| 172 | + .launchIn(viewModelScope) |
| 173 | + |
| 174 | + eventFlow.filterIsInstance<Event.OnConfirmRequested>() |
| 175 | + .onEach { onConfirmRequested() } |
| 176 | + .launchIn(viewModelScope) |
| 177 | + } |
| 178 | + |
| 179 | + private fun checkBalanceLimit(): Boolean { |
| 180 | + val amount = stateFlow.value.amountAnimatedModel.amountData.amount |
| 181 | + val max = stateFlow.value.maxSend ?: return false |
| 182 | + val entered = Fiat(amount, max.second) |
| 183 | + val balance = tokenCoordinator.balanceForToken(stateFlow.value.token ?: return false) |
| 184 | + val balanceInLocal = balance.convertingTo(exchange.preferredRate) |
| 185 | + val isOverBalance = entered.valueGreaterThan(balanceInLocal) |
| 186 | + if (isOverBalance) { |
| 187 | + BottomBarManager.showAlert( |
| 188 | + resources.getString(R.string.error_title_insufficientFunds), |
| 189 | + resources.getString(R.string.error_description_insufficientFunds), |
| 190 | + ) |
| 191 | + } |
| 192 | + return isOverBalance |
| 193 | + } |
| 194 | + |
| 195 | + private fun checkSendLimit(): Boolean { |
| 196 | + val amount = stateFlow.value.amountAnimatedModel.amountData.amount |
| 197 | + val currency = stateFlow.value.currencyModel |
| 198 | + val sendLimit = |
| 199 | + currency.code?.let { stateFlow.value.limits?.sendLimitFor(it) } ?: SendLimit.Zero |
| 200 | + val isOverLimit = amount > sendLimit.nextTransaction |
| 201 | + if (isOverLimit) { |
| 202 | + BottomBarManager.showAlert( |
| 203 | + resources.getString(R.string.error_title_sendLimitReached), |
| 204 | + resources.getString(R.string.error_description_sendLimitReached), |
| 205 | + ) |
| 206 | + } |
| 207 | + return isOverLimit |
| 208 | + } |
| 209 | + |
| 210 | + private fun onConfirmRequested() { |
| 211 | + if (checkBalanceLimit() || checkSendLimit()) return |
| 212 | + |
| 213 | + val enteredAmount = numberInputHelper.amount |
| 214 | + if (enteredAmount <= 0) return |
| 215 | + |
| 216 | + val token = stateFlow.value.token ?: return |
| 217 | + val rate = exchange.preferredRate |
| 218 | + dispatchEvent(Event.AmountConfirmed(Fiat(enteredAmount, rate.currency), token)) |
| 219 | + } |
| 220 | + |
| 221 | + companion object { |
| 222 | + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> |
| 223 | + when (event) { |
| 224 | + is Event.TokenUpdated -> { state -> |
| 225 | + state.copy(token = event.token) |
| 226 | + } |
| 227 | + is Event.CurrencyChanged -> { state -> |
| 228 | + state.copy(currencyModel = event.currency) |
| 229 | + } |
| 230 | + is Event.OnAmountChanged -> { state -> |
| 231 | + state.copy(amountAnimatedModel = event.model) |
| 232 | + } |
| 233 | + is Event.LimitsChanged -> { state -> |
| 234 | + state.copy(limits = event.limits) |
| 235 | + } |
| 236 | + is Event.MaxSendDetermined -> { state -> |
| 237 | + state.copy(maxSend = event.max to event.currencyCode) |
| 238 | + } |
| 239 | + is Event.OnNumberPressed, |
| 240 | + is Event.OnDecimalPressed, |
| 241 | + is Event.OnBackspace, |
| 242 | + is Event.OnEnteredNumberChanged, |
| 243 | + is Event.OnConfirmRequested, |
| 244 | + is Event.AmountConfirmed -> { state -> state } |
| 245 | + } |
| 246 | + } |
| 247 | + } |
| 248 | +} |
0 commit comments