Skip to content

Commit 7f451d8

Browse files
committed
feat(convert): currency-to-currency Convert flow
Ports the Convert half of iOS #604. The Convert tile on the v2 currency screen now opens a real conversion flow instead of re-routing to Buy: - `SwapPurpose.Convert(mint, destinationMint)` — a `BalanceDecrease`, so amount entry, max-amount, limits and balance checks reuse Sell semantics. `mint` is the currency being spent; `destinationMint` is what lands. - Amount entry grows an optional `accessory` slot (threaded through `AmountWithKeypad` / `AmountEntryScreen` / `SwapEntryScreenContent`) that renders the "Convert to <currency>" picker between the amount and keypad. - Destination picking is a flow step (`SwapStep.ConvertDestinationSelection`) backed by `TokenPurpose.ConvertDestination`, which lists every currency except the source — including ones the user doesn't hold yet — and check-marks the in-flight destination rather than the globally selected token. - New confirmation step (`SwapStep.ConvertReceipt`) mirroring the Sell receipt: you convert / amount / conversion fee / you receive. Fee handling follows the direction of the trade. Converting *out of* Dollars has no launchpad sale to skim from, so the 1% fee is charged on top of the entered amount (debit = entered + fee, receive = entered); every other direction takes the source token's `sellFeeBps` out of the entered amount (debit = entered, receive = entered - fee). Submission picks the matching leg: destination USDF -> sell, source USDF -> buy with an explicit fee, otherwise a direct swap. `OnPurposeChanged` now preserves an in-flight Convert destination when the source still matches — the entry step re-dispatches its route purpose on every re-entry, which otherwise reset the destination on returning from the picker.
1 parent 7fd9339 commit 7f451d8

20 files changed

Lines changed: 783 additions & 7 deletions

File tree

‎apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ fun AmountWithKeypad(
2626
hint: String = "",
2727
isError: Boolean = false,
2828
isClickable: Boolean = false,
29+
/** Optional row rendered between the amount and the keypad (e.g. a Convert destination picker). */
30+
accessory: (@Composable () -> Unit)? = null,
2931
onNumberPressed: (Int) -> Unit,
3032
onAmountClicked: () -> Unit = { },
3133
onBackspace: () -> Unit,
@@ -64,6 +66,15 @@ fun AmountWithKeypad(
6466
)
6567
}
6668

69+
accessory?.let {
70+
Box(
71+
modifier = Modifier
72+
.fillMaxWidth()
73+
.padding(horizontal = CodeTheme.dimens.inset)
74+
.padding(bottom = CodeTheme.dimens.grid.x3),
75+
) { it() }
76+
}
77+
6778
CodeKeyPad(
6879
modifier = Modifier
6980
.fillMaxWidth()

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ sealed interface AppRoute : NavKey, Parcelable {
224224
}
225225
}
226226
is SwapPurpose.Sell -> listOf(SwapStep.Entry(purpose, initialAmount = shortfall))
227+
is SwapPurpose.Convert -> listOf(SwapStep.Entry(purpose, initialAmount = shortfall))
227228
}
228229

229230

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ sealed interface SwapStep : FlowStep, Parcelable {
2727
@Serializable
2828
data object SellReceipt : SwapStep
2929

30+
@Parcelize
31+
@Serializable
32+
data object ConvertDestinationSelection : SwapStep
33+
34+
@Parcelize
35+
@Serializable
36+
data object ConvertReceipt : SwapStep
37+
3038
@Parcelize
3139
@Serializable
3240
data object PhantomConnect: SwapStep

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ sealed interface TokenPurpose: Parcelable {
1717

1818
}
1919
@Serializable data class Swap(val desiredToken: Mint, val amount: Fiat) : TokenPurpose
20+
21+
/**
22+
* Picks the currency a Convert lands in. [source] is the currency being spent (excluded from
23+
* the list); [current] is the destination already chosen, shown with a checkmark.
24+
*/
25+
@Serializable data class ConvertDestination(val source: Mint, val current: Mint) : TokenPurpose
2026
@Serializable data class LaunchFunding(val amount: Fiat): TokenPurpose
2127
@Serializable data class Tip(val amount: Fiat?): TriggersChange
2228
@Serializable data object Withdraw: TokenPurpose

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,14 @@ sealed interface SwapPurpose : Parcelable {
3232
val fundingSource: FundingSource = FundingSource.Flexible,
3333
) : SwapPurpose, BalanceIncrease
3434
@Serializable data class Sell(override val mint: Mint) : SwapPurpose, BalanceDecrease
35+
36+
/**
37+
* Converts one held currency directly into another. [mint] is the *source* currency the amount
38+
* is entered in (so balance/limit semantics match [Sell]); [destinationMint] is what the user
39+
* receives.
40+
*/
41+
@Serializable data class Convert(
42+
override val mint: Mint,
43+
val destinationMint: Mint,
44+
) : SwapPurpose, BalanceDecrease
3545
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,17 @@
567567
<string name="title_addingMoney">Adding Money</string>
568568
<string name="title_sellingToken">Selling %1$s</string>
569569
<string name="label_sellWarning">Review the above before confirming.\nOnce made, your transaction is irreversible.</string>
570+
571+
<!-- Currency conversion (Convert flow) -->
572+
<string name="title_amountToConvert">Convert</string>
573+
<string name="title_confirmConversion">Convert</string>
574+
<string name="title_convertingToken">Converting %1$s</string>
575+
<string name="title_convertTo">Convert To</string>
576+
<string name="label_convertTo">Convert to</string>
577+
<string name="label_amountToConvert">Amount to convert</string>
578+
<string name="label_conversionFee">Conversion fee</string>
579+
<string name="subtitle_youConvert">You Convert</string>
580+
<string name="action_confirmConversion">Confirm</string>
570581
<string name="label_buyWarning">Review the above before confirming.\nOnce made, your transaction is irreversible.</string>
571582

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

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

Lines changed: 33 additions & 1 deletion
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.ConvertDestinationSelector
2021
import com.flipcash.app.tokens.internal.SwapEntryScreenContent
2122
import com.flipcash.app.tokens.ui.SwapViewModel
2223
import com.flipcash.features.tokens.R
@@ -51,6 +52,7 @@ internal fun SwapEntryScreen(
5152
title = when (purpose) {
5253
is SwapPurpose.Buy if purpose.fundingSource != FundingSource.Flexible ->
5354
stringResource(R.string.title_amountToAdd)
55+
is SwapPurpose.Convert -> stringResource(R.string.title_amountToConvert)
5456
is SwapPurpose.BalanceIncrease -> stringResource(R.string.title_amountToBuy)
5557
is SwapPurpose.BalanceDecrease -> stringResource(R.string.title_amountToSell)
5658
},
@@ -64,7 +66,21 @@ internal fun SwapEntryScreen(
6466
}
6567
)
6668

67-
SwapEntryScreenContent(viewModel)
69+
SwapEntryScreenContent(
70+
viewModel = viewModel,
71+
accessory = if (purpose is SwapPurpose.Convert) {
72+
{
73+
ConvertDestinationSelector(
74+
destination = state.destinationTokenWithBalance,
75+
onClick = {
76+
viewModel.dispatchEvent(SwapViewModel.Event.SelectConvertDestination)
77+
},
78+
)
79+
}
80+
} else {
81+
null
82+
},
83+
)
6884
}
6985

7086
LaunchedEffect(viewModel) {
@@ -90,6 +106,22 @@ internal fun SwapEntryScreen(
90106
}.launchIn(this)
91107
}
92108

109+
LaunchedEffect(viewModel) {
110+
viewModel.eventFlow
111+
.filterIsInstance<SwapViewModel.Event.SelectConvertDestination>()
112+
.onEach {
113+
flowNavigator.navigateTo(SwapStep.ConvertDestinationSelection)
114+
}.launchIn(this)
115+
}
116+
117+
LaunchedEffect(viewModel) {
118+
viewModel.eventFlow
119+
.filterIsInstance<SwapViewModel.Event.ShowConvertReceipt>()
120+
.onEach {
121+
flowNavigator.navigateTo(SwapStep.ConvertReceipt)
122+
}.launchIn(this)
123+
}
124+
93125
LaunchedEffect(viewModel) {
94126
viewModel.eventFlow
95127
.filterIsInstance<SwapViewModel.Event.Exit>()

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.flipcash.app.tokens
22

33
import androidx.compose.runtime.Composable
44
import androidx.compose.runtime.LaunchedEffect
5+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
56
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
67
import androidx.navigation3.runtime.NavEntry
78
import androidx.navigation3.runtime.NavKey
@@ -97,6 +98,10 @@ private fun swapEntryProvider(
9798
BuyReceiptScreen()
9899
}
99100
annotatedEntry<SwapStep.SellReceipt> { SellReceiptScreen() }
101+
annotatedEntry<SwapStep.ConvertDestinationSelection> {
102+
ConvertDestinationSelectScreen()
103+
}
104+
annotatedEntry<SwapStep.ConvertReceipt> { ConvertReceiptScreen() }
100105
annotatedEntry<SwapStep.PhantomConnect> {
101106
PhantomConnectConfirmationScreen(depositFirstPurpose = depositFirstPurpose)
102107
}
@@ -108,6 +113,34 @@ private fun swapEntryProvider(
108113
}
109114
}
110115

116+
/**
117+
* Destination picker for a conversion. Selecting a currency updates the in-flight purpose and pops
118+
* straight back to amount entry — nothing else in the flow changes, so there's no resolve to await.
119+
*/
120+
@Composable
121+
private fun ConvertDestinationSelectScreen() {
122+
val selectionViewModel = hiltViewModel<SelectTokenViewModel>()
123+
val viewModel = flowSharedViewModel<SwapViewModel>()
124+
val flowNavigator = rememberFlowNavigator<SwapStep, SwapResult>()
125+
val purpose = viewModel.stateFlow.collectAsStateWithLifecycle().value.purpose
126+
127+
val convert = purpose as? SwapPurpose.Convert ?: return
128+
129+
TokenSelectScreen(TokenPurpose.ConvertDestination(convert.mint, convert.destinationMint))
130+
131+
LaunchedEffect(selectionViewModel) {
132+
selectionViewModel.eventFlow
133+
.filterIsInstance<SelectTokenViewModel.Event.OnTokenSelected>()
134+
.filter { it.fromUser }
135+
.map { it.mint }
136+
.onEach {
137+
viewModel.dispatchEvent(SwapViewModel.Event.OnDestinationSelected(it))
138+
flowNavigator.back()
139+
}
140+
.launchIn(this)
141+
}
142+
}
143+
111144
@Composable
112145
private fun SwapPurchaseTokenSelectScreen(targetMint: Mint, amount: Fiat) {
113146
val selectionViewModel = hiltViewModel<SelectTokenViewModel>()
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.flipcash.app.tokens
2+
3+
import androidx.compose.foundation.layout.Column
4+
import androidx.compose.foundation.layout.fillMaxSize
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.LaunchedEffect
7+
import androidx.compose.runtime.getValue
8+
import androidx.compose.ui.Alignment
9+
import androidx.compose.ui.Modifier
10+
import androidx.compose.ui.res.stringResource
11+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
12+
import com.flipcash.app.core.tokens.SwapResult
13+
import com.flipcash.app.core.tokens.SwapStep
14+
import com.flipcash.app.tokens.internal.TokenConvertReceiptScreen
15+
import com.flipcash.app.tokens.ui.SwapViewModel
16+
import com.flipcash.features.tokens.R
17+
import com.getcode.navigation.flow.flowSharedViewModel
18+
import com.getcode.navigation.flow.rememberFlowNavigator
19+
import com.getcode.ui.components.AppBarWithTitle
20+
import kotlinx.coroutines.flow.filterIsInstance
21+
import kotlinx.coroutines.flow.launchIn
22+
import kotlinx.coroutines.flow.onEach
23+
24+
@Composable
25+
internal fun ConvertReceiptScreen() {
26+
val flowNavigator = rememberFlowNavigator<SwapStep, SwapResult>()
27+
val viewModel = flowSharedViewModel<SwapViewModel>()
28+
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
29+
30+
Column(
31+
modifier = Modifier.fillMaxSize(),
32+
horizontalAlignment = Alignment.CenterHorizontally,
33+
) {
34+
AppBarWithTitle(
35+
title = stringResource(R.string.title_confirmConversion),
36+
titleAlignment = Alignment.CenterHorizontally,
37+
onBackIconClicked = {
38+
if (state.sellProgress.loading) {
39+
// swallow
40+
} else {
41+
flowNavigator.back()
42+
}
43+
}
44+
)
45+
46+
TokenConvertReceiptScreen(viewModel)
47+
}
48+
49+
LaunchedEffect(viewModel) {
50+
viewModel.eventFlow
51+
.filterIsInstance<SwapViewModel.Event.OnConvertSubmitted>()
52+
.onEach {
53+
flowNavigator.navigateTo(SwapStep.Processing)
54+
}.launchIn(this)
55+
}
56+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ fun TokenSelectScreen(
9595
is TokenPurpose.Tip -> Unit
9696
is TokenPurpose.LaunchFunding -> Unit
9797
is TokenPurpose.Swap -> Unit
98+
is TokenPurpose.ConvertDestination -> Unit
9899
}
99100
}.launchIn(this)
100101
}

0 commit comments

Comments
 (0)