Skip to content

Commit de1f65e

Browse files
committed
feat(tokens): v2 "Get" refresh — inline funding source + conversion-style receipt
Ports the Get (buy) half of iOS #604. Gated on FeatureFlag.NewUi via State.isGet, so the v1 buy flow is untouched when the flag is off. v1 asks for an amount, then pushes a funding-token step, then a receipt. v2 picks the payment source inline on the amount screen — so the entry ceiling is that one balance, the fee can be priced before confirming, and confirming lands straight on the receipt. - SwapViewModel: seeds a default funding source (Dollars, else the largest held balance), resolves it without pricing the buy, and re-points maxAmountFlow/transactionLimit at that balance. - Fee convention: Dollars has no launchpad sale to skim, so a v2 Get pays the flat house rate *on top* of the entered amount (mirroring Convert-from-Dollars) and sends it explicitly on the buy request. v1's USDF buy stays free; cross-currency still grosses up. - Buy-maximum math follows suit: on-top affords balance / (1 + f), where grossed-up affords balance × (1 - f). - SwapStep.FundingSelection renders the "Select Currency" half sheet. - Receipt reframes as a conversion: "You Get / Amount to convert / Conversion fee", with a "Confirm" button. - ConvertDestinationSelector.kt -> TokenSelectorRow.kt, now backing both "Convert to" and "Get with".
1 parent 7abe351 commit de1f65e

7 files changed

Lines changed: 377 additions & 55 deletions

File tree

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,12 @@
579579
<string name="subtitle_youConvert">You Convert</string>
580580
<string name="subtitle_amountAvailable">%1$s available</string>
581581
<string name="action_confirmConversion">Confirm</string>
582+
583+
<!-- Currency purchase (v2 Get flow) -->
584+
<string name="title_get">Get</string>
585+
<string name="label_getWith">Get with</string>
586+
<string name="subtitle_youGet">You Get</string>
587+
<string name="action_confirm">Confirm</string>
582588
<string name="label_buyWarning">Review the above before confirming.\nOnce made, your transaction is irreversible.</string>
583589

584590
<string name="label_yes">Yes</string>

‎apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt‎

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.flipcash.app.core.tokens.SwapStep
1717
import com.flipcash.app.core.verification.VerificationResult
1818
import com.flipcash.app.onramp.CoinbaseOnRampCompletion
1919
import com.flipcash.app.onramp.LocalCoinbaseOnRampController
20+
import com.flipcash.app.tokens.internal.BuyFundingSelector
2021
import com.flipcash.app.tokens.internal.ConvertDestinationSelector
2122
import com.flipcash.app.tokens.internal.SwapEntryScreenContent
2223
import com.flipcash.app.tokens.ui.SwapViewModel
@@ -28,6 +29,7 @@ import com.getcode.navigation.results.NavResultOrCanceled
2829
import com.getcode.navigation.results.navigateForResult
2930
import com.getcode.opencode.model.financial.Fiat
3031
import com.getcode.ui.components.AppBarWithTitle
32+
import kotlinx.coroutines.flow.filter
3133
import kotlinx.coroutines.flow.filterIsInstance
3234
import kotlinx.coroutines.flow.launchIn
3335
import kotlinx.coroutines.flow.map
@@ -53,6 +55,9 @@ internal fun SwapEntryScreen(
5355
is SwapPurpose.Buy if purpose.fundingSource != FundingSource.Flexible ->
5456
stringResource(R.string.title_amountToAdd)
5557
is SwapPurpose.Convert -> stringResource(R.string.title_amountToConvert)
58+
// v2 renames the direct buy to "Get" and states the amount in the header instead
59+
// of the title, matching Convert.
60+
is SwapPurpose.BalanceIncrease if state.isGet -> stringResource(R.string.title_get)
5661
is SwapPurpose.BalanceIncrease -> stringResource(R.string.title_amountToBuy)
5762
is SwapPurpose.BalanceDecrease -> stringResource(R.string.title_amountToSell)
5863
},
@@ -68,18 +73,31 @@ internal fun SwapEntryScreen(
6873

6974
SwapEntryScreenContent(
7075
viewModel = viewModel,
71-
largeHeader = purpose is SwapPurpose.Convert,
72-
accessory = if (purpose is SwapPurpose.Convert) {
73-
{
74-
ConvertDestinationSelector(
75-
destination = state.destinationTokenWithBalance,
76-
onClick = {
77-
viewModel.dispatchEvent(SwapViewModel.Event.SelectConvertDestination)
78-
},
79-
)
76+
largeHeader = purpose is SwapPurpose.Convert || state.isGet,
77+
accessory = when {
78+
purpose is SwapPurpose.Convert -> {
79+
{
80+
ConvertDestinationSelector(
81+
destination = state.destinationTokenWithBalance,
82+
onClick = {
83+
viewModel.dispatchEvent(SwapViewModel.Event.SelectConvertDestination)
84+
},
85+
)
86+
}
87+
}
88+
// v2 Get picks what pays for the buy here, so the amount can be capped and the
89+
// fee priced before confirming. v1 asks on a pushed step afterwards instead.
90+
state.isGet -> {
91+
{
92+
BuyFundingSelector(
93+
funding = state.fundingTokenWithBalance,
94+
onClick = {
95+
viewModel.dispatchEvent(SwapViewModel.Event.SelectBuyFundingSource)
96+
},
97+
)
98+
}
8099
}
81-
} else {
82-
null
100+
else -> null
83101
},
84102
)
85103
}
@@ -99,6 +117,25 @@ internal fun SwapEntryScreen(
99117
.launchIn(this)
100118
}
101119

120+
LaunchedEffect(viewModel) {
121+
viewModel.eventFlow
122+
.filterIsInstance<SwapViewModel.Event.SelectBuyFundingSource>()
123+
.onEach { flowNavigator.navigateTo(SwapStep.FundingSelection) }
124+
.launchIn(this)
125+
}
126+
127+
// v2 Get confirms straight from here, so the receipt push lives here too. Resolving the
128+
// funding token is async and can fail (stale rates), in which case OnFundingTokenResolved
129+
// never fires and its alert surfaces on this screen. Gated on isGet so v1 — where the token
130+
// select screen owns this push and this screen is still in the stack — doesn't double-navigate.
131+
LaunchedEffect(viewModel) {
132+
viewModel.eventFlow
133+
.filterIsInstance<SwapViewModel.Event.OnFundingTokenResolved>()
134+
.filter { viewModel.stateFlow.value.isGet }
135+
.onEach { flowNavigator.navigateTo(SwapStep.BuyReceipt) }
136+
.launchIn(this)
137+
}
138+
102139
LaunchedEffect(viewModel) {
103140
viewModel.eventFlow
104141
.filterIsInstance<SwapViewModel.Event.ShowSellReceipt>()

‎apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ private fun swapEntryProvider(
120120
annotatedEntry<SwapStep.ConvertDestinationSelection> {
121121
ConvertDestinationSelectScreen()
122122
}
123+
annotatedEntry<SwapStep.FundingSelection> {
124+
BuyFundingSelectScreen()
125+
}
123126
annotatedEntry<SwapStep.ConvertReceipt> { ConvertReceiptScreen() }
124127
annotatedEntry<SwapStep.PhantomConnect> {
125128
PhantomConnectConfirmationScreen(depositFirstPurpose = depositFirstPurpose)
@@ -189,6 +192,57 @@ private fun ConvertDestinationSelectScreen() {
189192
}
190193
}
191194

195+
/**
196+
* Payment-source picker for a v2 Get. Selecting a currency re-points the entry cap and pops back to
197+
* amount entry — unlike [SwapPurchaseTokenSelectScreen], which prices the buy and pushes a receipt.
198+
*/
199+
@Composable
200+
private fun BuyFundingSelectScreen() {
201+
val selectionViewModel = hiltViewModel<SelectTokenViewModel>()
202+
val viewModel = flowSharedViewModel<SwapViewModel>()
203+
val flowNavigator = rememberFlowNavigator<SwapStep, SwapResult>()
204+
val state = viewModel.stateFlow.collectAsStateWithLifecycle().value
205+
206+
val buy = state.purpose as? SwapPurpose.Buy ?: return
207+
val current = state.fundingMint ?: return
208+
209+
Column(
210+
modifier = Modifier
211+
.fillMaxWidth()
212+
.navigationBarsPadding(),
213+
) {
214+
Text(
215+
modifier = Modifier
216+
.padding(horizontal = CodeTheme.dimens.inset)
217+
.padding(
218+
top = CodeTheme.dimens.staticGrid.x7,
219+
bottom = CodeTheme.dimens.staticGrid.x5,
220+
),
221+
text = stringResource(R.string.title_selectCurrency),
222+
style = CodeTheme.typography.textLarge,
223+
color = CodeTheme.colors.textMain,
224+
)
225+
226+
TokenSelectScreen(
227+
purpose = TokenPurpose.BuyFunding(target = buy.mint, current = current),
228+
showTopBar = false,
229+
presentation = TokenListPresentation.Sheet,
230+
)
231+
}
232+
233+
LaunchedEffect(selectionViewModel) {
234+
selectionViewModel.eventFlow
235+
.filterIsInstance<SelectTokenViewModel.Event.OnTokenSelected>()
236+
.filter { it.fromUser }
237+
.map { it.mint }
238+
.onEach {
239+
viewModel.dispatchEvent(SwapViewModel.Event.OnFundingSourceSelected(it))
240+
flowNavigator.back()
241+
}
242+
.launchIn(this)
243+
}
244+
}
245+
192246
@Composable
193247
private fun SwapPurchaseTokenSelectScreen(targetMint: Mint, amount: Fiat) {
194248
val selectionViewModel = hiltViewModel<SelectTokenViewModel>()

‎apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuyReceiptScreen.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ internal fun BuyReceiptScreen() {
3232
horizontalAlignment = Alignment.CenterHorizontally,
3333
) {
3434
AppBarWithTitle(
35-
title = stringResource(R.string.title_confirmPurchase),
35+
title = stringResource(
36+
if (state.isGet) R.string.title_get else R.string.title_confirmPurchase
37+
),
3638
titleAlignment = Alignment.CenterHorizontally,
3739
onBackIconClicked = { flowNavigator.back() }
3840
)

‎apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ private fun TokenBuyReceiptScreen(
8181
.navigationBarsPadding()
8282
.imePadding()
8383
.padding(bottom = CodeTheme.dimens.grid.x3),
84-
text = stringResource(R.string.action_buy),
84+
text = stringResource(
85+
if (state.isGet) R.string.action_confirm else R.string.action_buy
86+
),
8587
buttonState = ButtonState.Filled,
8688
isLoading = state.buyProgress.loading,
8789
isSuccess = state.buyProgress.success,
@@ -108,6 +110,7 @@ private fun TokenBuyReceiptScreen(
108110
desiredToken = state.tokenWithBalance?.token,
109111
purchaseAmount = state.confirmedEnteredAmount,
110112
feeAmount = state.feeAmount,
113+
isGet = state.isGet,
111114
)
112115
}
113116
}
@@ -119,6 +122,10 @@ private fun BuyReceipt(
119122
desiredToken: Token?,
120123
purchaseAmount: Fiat?,
121124
feeAmount: Fiat,
125+
// v2 reframes the buy as a conversion between two currencies the user holds, so the receipt
126+
// reads "You Get / Amount to convert / Conversion fee" — the same wording Convert uses. The
127+
// math is identical either way: "You Pay" is always purchase + fee.
128+
isGet: Boolean,
122129
modifier: Modifier = Modifier,
123130
) {
124131
// The funding token and confirmed amount resolve asynchronously after landing here, so
@@ -155,7 +162,9 @@ private fun BuyReceipt(
155162
horizontalAlignment = Alignment.CenterHorizontally,
156163
) {
157164
Text(
158-
text = stringResource(R.string.subtitle_youReceive),
165+
text = stringResource(
166+
if (isGet) R.string.subtitle_youGet else R.string.subtitle_youReceive
167+
),
159168
style = CodeTheme.typography.textSmall,
160169
color = CodeTheme.colors.textSecondary,
161170
)
@@ -189,12 +198,16 @@ private fun BuyReceipt(
189198
) {
190199
ReceiptLineItem(
191200
modifier = Modifier.fillMaxWidth(),
192-
label = stringResource(R.string.label_amountToBuy),
201+
label = stringResource(
202+
if (isGet) R.string.label_amountToConvert else R.string.label_amountToBuy
203+
),
193204
amount = purchaseAmount.formatted()
194205
)
195206
ReceiptLineItem(
196207
modifier = Modifier.fillMaxWidth(),
197-
label = stringResource(R.string.label_exchangeFee),
208+
label = stringResource(
209+
if (isGet) R.string.label_conversionFee else R.string.label_exchangeFee
210+
),
198211
amount = feeAmount.formatted(
199212
extraPrefix = if (feeAmount.decimalValue < 0.01) "~ " else null,
200213
)

apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/ConvertDestinationSelector.kt renamed to apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectorRow.kt

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,27 @@ import com.getcode.opencode.model.financial.TokenWithBalance
2020
import com.getcode.theme.CodeTheme
2121

2222
/**
23-
* "Convert to [🪙 Currency ⌄]" — the destination picker that sits between the entered amount and
24-
* the keypad on the Convert amount screen. Tapping it pushes the currency list as a flow step.
23+
* "Label [🪙 Currency ⌄]" — the inline currency picker that sits between the entered amount and
24+
* the keypad. Tapping the trailing chip pushes a currency list as a flow step.
25+
*
26+
* Convert and the v2 Get both pick a currency here; only the label and which side of the trade it
27+
* names differ, so they share this row via [ConvertDestinationSelector] and [BuyFundingSelector].
2528
*/
2629
@Composable
27-
internal fun ConvertDestinationSelector(
28-
destination: TokenWithBalance?,
30+
private fun TokenSelectorRow(
31+
label: String,
32+
selected: TokenWithBalance?,
2933
onClick: () -> Unit,
3034
modifier: Modifier = Modifier,
3135
) {
32-
destination ?: return
36+
selected ?: return
3337

3438
Row(
3539
modifier = modifier.fillMaxWidth(),
3640
verticalAlignment = Alignment.CenterVertically,
3741
) {
3842
Text(
39-
text = stringResource(R.string.label_convertTo),
43+
text = label,
4044
style = CodeTheme.typography.textMedium,
4145
color = CodeTheme.colors.textMain,
4246
)
@@ -49,11 +53,11 @@ internal fun ConvertDestinationSelector(
4953
verticalAlignment = Alignment.CenterVertically,
5054
) {
5155
TokenIconWithName(
52-
token = destination.token,
56+
token = selected.token,
5357
imageSize = CodeTheme.dimens.staticGrid.x6,
5458
textStyle = CodeTheme.typography.textMedium,
5559
spacing = CodeTheme.dimens.grid.x2,
56-
displayName = { destination.displayName },
60+
displayName = { selected.displayName },
5761
)
5862

5963
Icon(
@@ -65,3 +69,29 @@ internal fun ConvertDestinationSelector(
6569
}
6670
}
6771
}
72+
73+
/** "Convert to [🪙 Currency ⌄]" — what a conversion lands in. */
74+
@Composable
75+
internal fun ConvertDestinationSelector(
76+
destination: TokenWithBalance?,
77+
onClick: () -> Unit,
78+
modifier: Modifier = Modifier,
79+
) = TokenSelectorRow(
80+
label = stringResource(R.string.label_convertTo),
81+
selected = destination,
82+
onClick = onClick,
83+
modifier = modifier,
84+
)
85+
86+
/** "Get with [🪙 Currency ⌄]" — what a v2 Get is paid from. */
87+
@Composable
88+
internal fun BuyFundingSelector(
89+
funding: TokenWithBalance?,
90+
onClick: () -> Unit,
91+
modifier: Modifier = Modifier,
92+
) = TokenSelectorRow(
93+
label = stringResource(R.string.label_getWith),
94+
selected = funding,
95+
onClick = onClick,
96+
modifier = modifier,
97+
)

0 commit comments

Comments
 (0)