Skip to content

Commit 0ca5934

Browse files
committed
refactor(ui): extract shared AmountEntryScreen and AmountEntryDelegate
Extract duplicated amount-entry UI and ViewModel logic into a shared module (apps/flipcash/shared/amount-entry). The new AmountEntryDelegate encapsulates NumberInputHelper, currency observation, and animated model management. The new AmountEntryScreen composable provides the unified Column { AmountWithKeypad + CodeButton/SlideToConfirm } layout. Migrated Cash (Give), Swap (Buy/Sell), and Withdrawal features to use the shared components, eliminating ~250 lines of duplicated code. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 65ed2e5 commit 0ca5934

21 files changed

Lines changed: 650 additions & 896 deletions

File tree

apps/flipcash/features/cash/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies {
1414
testImplementation(project(":libs:test-utils"))
1515

1616
implementation(libs.kotlin.stdlib)
17+
implementation(project(":apps:flipcash:shared:amount-entry"))
1718
implementation(project(":apps:flipcash:shared:analytics"))
1819
implementation(project(":apps:flipcash:shared:session"))
1920
implementation(project(":apps:flipcash:shared:tokens"))
Lines changed: 9 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,21 @@
11
package com.flipcash.app.cash.internal
22

3-
import androidx.compose.foundation.layout.Box
4-
import androidx.compose.foundation.layout.Column
5-
import androidx.compose.foundation.layout.fillMaxSize
6-
import androidx.compose.foundation.layout.fillMaxWidth
7-
import androidx.compose.foundation.layout.navigationBarsPadding
8-
import androidx.compose.foundation.layout.padding
93
import androidx.compose.runtime.Composable
10-
import androidx.compose.runtime.collectAsState
114
import androidx.compose.runtime.getValue
12-
import androidx.compose.ui.Modifier
13-
import androidx.compose.ui.res.stringResource
5+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
146
import com.flipcash.app.core.AppRoute
15-
import com.flipcash.app.core.ui.AmountWithKeypad
16-
import com.flipcash.features.cash.R
7+
import com.flipcash.shared.amountentry.AmountEntryScreen
178
import com.getcode.navigation.core.LocalCodeNavigator
18-
import com.getcode.theme.CodeTheme
19-
import com.getcode.ui.theme.ButtonState
20-
import com.getcode.ui.theme.CodeButton
219

2210
@Composable
2311
internal fun GiveScreenContent(viewModel: CashScreenViewModel) {
2412
val navigator = LocalCodeNavigator.current
25-
val state by viewModel.stateFlow.collectAsState()
26-
Column(
27-
modifier = Modifier.fillMaxSize(),
28-
) {
29-
AmountWithKeypad(
30-
modifier = Modifier
31-
.fillMaxWidth()
32-
.weight(1f),
33-
amountAnimatedModel = state.amountAnimatedModel,
34-
currencyFlag = state.currencyModel.selected?.resId,
35-
prefix = state.currencyModel.selected?.symbol.orEmpty(),
36-
placeholder = "0",
37-
decimalPlaces = state.currencyModel.fractionUnits,
38-
hint = if (state.isError) {
39-
stringResource(
40-
R.string.subtitle_giveCashHintLimitExceeded,
41-
state.maxAvailableForGive
42-
)
43-
} else {
44-
stringResource(R.string.subtitle_giveCashHint, state.maxAvailableForGive)
45-
},
46-
isClickable = true,
47-
onAmountClicked = {
48-
navigator.push(AppRoute.Main.RegionSelection)
49-
},
50-
isError = state.isError,
51-
onNumberPressed = { viewModel.dispatchEvent(CashScreenViewModel.Event.OnNumberPressed(it)) },
52-
onBackspace = { viewModel.dispatchEvent(CashScreenViewModel.Event.OnBackspace) },
53-
onDecimal = { viewModel.dispatchEvent(CashScreenViewModel.Event.OnDecimalPressed) }
54-
)
13+
val config by viewModel.config.collectAsStateWithLifecycle()
5514

56-
Box(modifier = Modifier.fillMaxWidth()) {
57-
CodeButton(
58-
enabled = state.canGive,
59-
modifier = Modifier
60-
.fillMaxWidth()
61-
.padding(horizontal = CodeTheme.dimens.inset)
62-
.padding(bottom = CodeTheme.dimens.grid.x2)
63-
.navigationBarsPadding(),
64-
buttonState = ButtonState.Filled,
65-
isLoading = state.generatingBill.loading,
66-
isSuccess = state.generatingBill.success,
67-
text = stringResource(R.string.action_next),
68-
) {
69-
viewModel.dispatchEvent(CashScreenViewModel.Event.OnGive)
70-
}
71-
}
72-
}
15+
AmountEntryScreen(
16+
delegate = viewModel.amountDelegate,
17+
config = config,
18+
onConfirm = { viewModel.dispatchEvent(CashScreenViewModel.Event.OnGive) },
19+
onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) },
20+
)
7321
}

apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt

Lines changed: 54 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import com.flipcash.app.core.ui.CurrencyHolder
88
import com.flipcash.app.tokens.TokenCoordinator
99
import com.flipcash.features.cash.R
1010
import com.flipcash.libs.coroutines.DispatcherProvider
11+
import com.flipcash.shared.amountentry.AmountEntryAction
12+
import com.flipcash.shared.amountentry.AmountEntryConfig
13+
import com.flipcash.shared.amountentry.AmountEntryDelegate
14+
import com.flipcash.shared.amountentry.AmountEntryHint
1115
import com.getcode.manager.BottomBarAction
1216
import com.getcode.manager.BottomBarManager
1317
import com.getcode.opencode.controllers.TransactionOperations
1418
import com.getcode.opencode.exchange.Exchange
1519
import com.getcode.opencode.exchange.VerifiedFiatCalculator
1620
import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError
17-
import com.getcode.opencode.model.financial.Currency
1821
import com.getcode.opencode.model.financial.CurrencyCode
1922
import com.getcode.opencode.model.financial.Fiat
2023
import com.getcode.opencode.model.financial.Limits
@@ -23,13 +26,12 @@ import com.getcode.opencode.model.financial.SendLimit
2326
import com.getcode.opencode.model.financial.TokenWithLocalizedBalance
2427
import com.getcode.opencode.model.financial.minus
2528
import com.getcode.solana.keys.Mint
26-
import com.getcode.ui.components.text.AmountAnimatedInputUiModel
27-
import com.getcode.ui.components.text.NumberInputHelper
2829
import com.getcode.util.resources.ResourceHelper
2930
import com.getcode.view.BaseViewModel
3031
import com.getcode.view.LoadingSuccessState
3132
import dagger.hilt.android.lifecycle.HiltViewModel
3233
import kotlinx.coroutines.CompletableDeferred
34+
import kotlinx.coroutines.flow.SharingStarted
3335
import kotlinx.coroutines.flow.combine
3436
import kotlinx.coroutines.flow.distinctUntilChanged
3537
import kotlinx.coroutines.flow.drop
@@ -41,6 +43,7 @@ import kotlinx.coroutines.flow.launchIn
4143
import kotlinx.coroutines.flow.map
4244
import kotlinx.coroutines.flow.mapNotNull
4345
import kotlinx.coroutines.flow.onEach
46+
import kotlinx.coroutines.flow.stateIn
4447
import kotlinx.coroutines.flow.take
4548
import kotlinx.coroutines.launch
4649
import javax.inject.Inject
@@ -60,59 +63,31 @@ internal class CashScreenViewModel @Inject constructor(
6063
defaultDispatcher = dispatchers.Default,
6164
) {
6265

63-
private val numberInputHelper = NumberInputHelper()
66+
val amountDelegate = AmountEntryDelegate(exchange, viewModelScope)
6467
private val tokenInitialized = CompletableDeferred<Mint?>()
6568

6669
internal data class State(
6770
val selectedTokenAddress: Mint? = null,
6871
val token: TokenWithLocalizedBalance? = null,
6972
val currencyModel: CurrencyHolder = CurrencyHolder(),
70-
val amountAnimatedModel: AmountAnimatedInputUiModel = AmountAnimatedInputUiModel(),
7173
val limits: Limits? = null,
7274
val maxForGive: Pair<Double, CurrencyCode>? = null,
7375
val generatingBill: LoadingSuccessState = LoadingSuccessState(),
7476
) {
75-
val canGive: Boolean
76-
get() = (amountAnimatedModel.amountData.amount) > 0.00
77-
7877
val maxAvailableForGive: String
7978
get() = maxForGive?.let { Fiat(it.first, it.second).formatted() }.orEmpty()
80-
81-
82-
val isError: Boolean
83-
get() {
84-
if (amountAnimatedModel.amountData.isEmpty()) return false
85-
if (maxForGive != null) {
86-
val enteredAmount = Fiat(
87-
fiat = amountAnimatedModel.amountData.amount,
88-
currencyCode = maxForGive.second
89-
)
90-
val limit = Fiat(maxForGive.first, maxForGive.second)
91-
if (enteredAmount.valueLessThanOrEqualTo(limit)) {
92-
return false
93-
}
94-
}
95-
96-
return true
97-
}
9879
}
9980

10081
sealed interface Event {
10182
data class InitializeToken(val mint: Mint?) : Event
10283
data class OnTokenSelected(val address: Mint) : Event
10384
data class OnTokenUpdated(val token: TokenWithLocalizedBalance) : Event
104-
data class OnNumberPressed(val number: Int) : Event
105-
data object OnDecimalPressed : Event
106-
data object OnBackspace : Event
107-
data class OnEnteredNumberChanged(val backspace: Boolean = false) : Event
108-
data class OnAmountChanged(val amountAnimatedModel: AmountAnimatedInputUiModel) : Event
109-
data class OnCurrencyChanged(val model: Currency) : Event
85+
data class OnCurrencyChanged(val model: com.getcode.opencode.model.financial.Currency) : Event
11086
data class OnMaxDetermined(val max: Double, val currencyCode: CurrencyCode) : Event
11187
data class OnLimitsChanged(val limits: Limits?) : Event
11288
data object OnGive : Event
11389
data class PresentBill(val bill: Bill.Cash) : Event
11490

115-
11691
data class AddCashToWallet(val amount: Fiat) : Event
11792
data class UpdateLoadingState(val loading: Boolean = false, val success: Boolean = false) :
11893
Event
@@ -123,7 +98,7 @@ internal class CashScreenViewModel @Inject constructor(
12398
val checkBalanceLimit: () -> Boolean = {
12499
// this balance check differs from withdrawal due to the fact this is a localized check
125100
// whereas withdrawal is USD locked
126-
val amount = stateFlow.value.amountAnimatedModel.amountData.amount
101+
val amount = amountDelegate.state.value.enteredAmount
127102
val enteredAmount = Fiat(
128103
fiat = amount,
129104
currencyCode = stateFlow.value.currencyModel.code ?: CurrencyCode.USD
@@ -170,7 +145,7 @@ internal class CashScreenViewModel @Inject constructor(
170145
isOverBalance
171146
}
172147
val checkSendLimit: () -> Boolean = {
173-
val amount = stateFlow.value.amountAnimatedModel.amountData.amount
148+
val amount = amountDelegate.state.value.enteredAmount
174149
val currency = stateFlow.value.currencyModel
175150
val sendLimit =
176151
currency.code?.let { stateFlow.value.limits?.sendLimitFor(it) } ?: SendLimit.Zero
@@ -185,8 +160,6 @@ internal class CashScreenViewModel @Inject constructor(
185160
}
186161

187162
init {
188-
numberInputHelper.reset()
189-
190163
eventFlow
191164
.filterIsInstance<Event.InitializeToken>()
192165
.take(1)
@@ -230,68 +203,13 @@ internal class CashScreenViewModel @Inject constructor(
230203
exchange.getCurrency(balance.rate.currency.name)
231204
}.onEach {
232205
dispatchEvent(Event.OnCurrencyChanged(it))
233-
}.launchIn(viewModelScope)
234-
235-
exchange.observePreferredRate()
236-
.onEach {
237-
// reset when entry rate changes
238-
numberInputHelper.reset()
239-
dispatchEvent(Event.OnAmountChanged(AmountAnimatedInputUiModel()))
206+
amountDelegate.onCurrencyChanged(it)
240207
}.launchIn(viewModelScope)
241208

242209
transactionController.limits
243210
.onEach { dispatchEvent(Event.OnLimitsChanged(it)) }
244211
.launchIn(viewModelScope)
245212

246-
eventFlow
247-
.filterIsInstance<Event.OnCurrencyChanged>()
248-
.map { it.model }
249-
.onEach {
250-
numberInputHelper.fractionUnits = it.fractionUnits
251-
}.launchIn(viewModelScope)
252-
253-
eventFlow
254-
.filterIsInstance<Event.OnNumberPressed>()
255-
.map { it.number }
256-
.onEach { number ->
257-
numberInputHelper.fractionUnits = stateFlow.value.currencyModel.fractionUnits
258-
numberInputHelper.maxLength = 10 // 1 billion dollars
259-
numberInputHelper.onNumber(number)
260-
dispatchEvent(Event.OnEnteredNumberChanged())
261-
}.launchIn(viewModelScope)
262-
263-
eventFlow
264-
.filterIsInstance<Event.OnDecimalPressed>()
265-
.onEach {
266-
numberInputHelper.onDot()
267-
dispatchEvent(Event.OnEnteredNumberChanged())
268-
}
269-
.launchIn(viewModelScope)
270-
271-
eventFlow
272-
.filterIsInstance<Event.OnBackspace>()
273-
.onEach {
274-
numberInputHelper.onBackspace()
275-
dispatchEvent(Event.OnEnteredNumberChanged(true))
276-
}.launchIn(viewModelScope)
277-
278-
eventFlow
279-
.filterIsInstance<Event.OnEnteredNumberChanged>()
280-
.map { it.backspace }
281-
.onEach { backspace ->
282-
val current = stateFlow.value.amountAnimatedModel
283-
val model = stateFlow.value.amountAnimatedModel
284-
val amount = numberInputHelper.getFormattedStringForAnimation(includeCommas = true)
285-
286-
val updated = model.copy(
287-
amountDataLast = current.amountData,
288-
amountData = amount,
289-
lastPressedBackspace = backspace
290-
)
291-
292-
dispatchEvent(Event.OnAmountChanged(updated))
293-
}.launchIn(viewModelScope)
294-
295213
stateFlow
296214
.filter { it.limits != null }
297215
.map { it.limits to (it.token?.balance ?: LocalFiat.Zero) }
@@ -304,15 +222,15 @@ internal class CashScreenViewModel @Inject constructor(
304222

305223
eventFlow
306224
.filterIsInstance<Event.OnGive>()
307-
.map { stateFlow.value.amountAnimatedModel }
308225
.filter { !(checkBalanceLimit() || checkSendLimit()) }
309-
.onEach { data ->
226+
.onEach {
310227
dispatchEvent(Event.UpdateLoadingState(loading = true))
311228
val (token, balance) = stateFlow.value.token!!
312229
val rate = exchange.preferredRate
230+
val amount = amountDelegate.state.value.enteredAmount
313231

314232
val result = verifiedFiatCalculator.compute(
315-
amount = Fiat(data.amountData.amount, rate.currency),
233+
amount = Fiat(amount, rate.currency),
316234
token = token,
317235
balance = balance.underlyingTokenAmount,
318236
rate = rate,
@@ -361,6 +279,45 @@ internal class CashScreenViewModel @Inject constructor(
361279
}
362280

363281

282+
val config: kotlinx.coroutines.flow.StateFlow<AmountEntryConfig> = combine(
283+
amountDelegate.state,
284+
stateFlow,
285+
) { delegateState, vmState ->
286+
val isError = run {
287+
if (delegateState.isEmpty) false
288+
else if (vmState.maxForGive != null) {
289+
val enteredAmount = Fiat(
290+
fiat = delegateState.enteredAmount,
291+
currencyCode = vmState.maxForGive.second
292+
)
293+
val limit = Fiat(vmState.maxForGive.first, vmState.maxForGive.second)
294+
!enteredAmount.valueLessThanOrEqualTo(limit)
295+
} else true
296+
}
297+
298+
AmountEntryConfig(
299+
hint = if (isError) {
300+
AmountEntryHint.Error(
301+
resources.getString(R.string.subtitle_giveCashHintLimitExceeded, vmState.maxAvailableForGive)
302+
)
303+
} else {
304+
AmountEntryHint.Info(
305+
resources.getString(R.string.subtitle_giveCashHint, vmState.maxAvailableForGive)
306+
)
307+
},
308+
canConfirm = delegateState.enteredAmount > 0.00,
309+
canChangeCurrency = true,
310+
action = AmountEntryAction(
311+
label = resources.getString(R.string.action_next),
312+
loadingState = vmState.generatingBill,
313+
),
314+
)
315+
}.stateIn(
316+
viewModelScope,
317+
SharingStarted.WhileSubscribed(5000),
318+
AmountEntryConfig(action = AmountEntryAction(label = "")),
319+
)
320+
364321
internal companion object {
365322
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
366323
when (event) {
@@ -372,22 +329,12 @@ internal class CashScreenViewModel @Inject constructor(
372329
state.copy(token = event.token)
373330
}
374331

375-
is Event.OnAmountChanged -> { state ->
376-
state.copy(
377-
amountAnimatedModel = event.amountAnimatedModel
378-
)
379-
}
380-
381332
is Event.InitializeToken -> { state -> state }
382333

383334
is Event.OpenScreen -> { state -> state }
384335

385-
Event.OnBackspace,
386336
Event.OnGive,
387-
is Event.OnEnteredNumberChanged,
388-
is Event.PresentBill,
389-
is Event.OnNumberPressed,
390-
Event.OnDecimalPressed -> { state -> state }
337+
is Event.PresentBill -> { state -> state }
391338

392339
is Event.OnCurrencyChanged -> { state ->
393340
state.copy(currencyModel = CurrencyHolder(event.model))

0 commit comments

Comments
 (0)