Skip to content

Commit a123376

Browse files
authored
feat(direct-send): add AmountEntryViewModel, AmountEntryScreen, and ConfirmationStyle (#823)
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 <git@bmcreations.dev>
1 parent 72b4782 commit a123376

4 files changed

Lines changed: 405 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.flipcash.app.core.ui
2+
3+
/** Determines the confirmation widget shown on amount entry screens. */
4+
enum class ConfirmationStyle {
5+
/** Standard tap-to-confirm button (CodeButton). */
6+
Button,
7+
/** Swipe-to-confirm slider (SlideToConfirm). */
8+
Slide,
9+
}

apps/flipcash/features/direct-send/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,6 @@ dependencies {
2424
implementation(project(":apps:flipcash:shared:featureflags"))
2525
implementation(project(":apps:flipcash:shared:permissions"))
2626
implementation(project(":apps:flipcash:shared:contacts"))
27+
implementation(project(":services:opencode"))
28+
implementation(project(":services:flipcash"))
2729
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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

Comments
 (0)