diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 1afb1e1d5..e3efa8cee 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -176,7 +176,6 @@ dependencies { implementation(project(":apps:flipcash:features:backupkey")) implementation(project(":apps:flipcash:features:shareapp")) implementation(project(":apps:flipcash:features:withdrawal")) - implementation(project(":apps:flipcash:features:onramp")) implementation(project(":apps:flipcash:features:contact-verification")) implementation(project(":apps:flipcash:features:tokens")) implementation(project(":apps:flipcash:features:transactions")) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 5e24cec9f..35f21d26e 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -40,7 +40,6 @@ import com.flipcash.app.login.router.LoginRouter import com.flipcash.app.login.seed.SeedInputScreen import com.flipcash.app.menu.MenuScreen import com.flipcash.app.myaccount.MyAccountScreen -import com.flipcash.app.onramp.OnRampCustomAmountScreen import com.flipcash.app.permissions.NotificationPermissionRationaleScreen import com.flipcash.app.permissions.NotificationPermissionScreen import com.flipcash.app.purchase.PurchaseAccountScreen @@ -113,7 +112,6 @@ fun appEntryProvider( annotatedEntry { key -> TokenTxProcessingScreen(key.swapId, key.swapPurpose, key.amount, key.awaitExternalWallet, key.isFundingShortfall) } - annotatedEntry { key -> OnRampCustomAmountScreen(key.mint) } annotatedEntry { TokenDiscoveryScreen() } annotatedEntry { key -> CurrencyCreatorFlowScreen(route = key, resultStateRegistry = resultStateRegistry) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index d4cac512e..11ef0c849 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -137,9 +137,15 @@ sealed interface AppRoute : NavKey, Parcelable { val shortfall: Fiat? = null, ) : Token, FlowRouteWithResult { override val initialStack: List - get() = listOf(SwapStep.Entry(purpose)) + get() = listOf(SwapStep.Entry(purpose, initialAmount = shortfall)) } + @Serializable + data object PhantomConnectInfo: Token + + @Serializable + data object PhantomConfirmTransaction: Token + @Serializable data class TxProcessing( val swapId: SwapId, @@ -149,9 +155,6 @@ sealed interface AppRoute : NavKey, Parcelable { val isFundingShortfall: Boolean = false, ) : Token, NonDismissableRoute, NonDraggableRoute - @Serializable - data class OnRamp(val mint: Mint) : Token - @Serializable data object Discovery: AppRoute @@ -209,7 +212,7 @@ private fun buildVerificationInitialStack( emailVerificationCode: String?, ): List { if (includePhone && includeEmail) { - return listOf(VerificationStep.Intro(origin is AppRoute.Token.OnRamp)) + return listOf(VerificationStep.Intro(origin is AppRoute.Token.Swap)) } if (includePhone) { return listOf(VerificationStep.PhoneEntry) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/extensions/Context.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/extensions/Context.kt index 59751be14..433385939 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/extensions/Context.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/extensions/Context.kt @@ -21,7 +21,6 @@ fun Context.launchPhotos(searchQuery: String = "Flipcash") { if (searchIntent.resolveActivity(packageManager) != null) { startActivity(searchIntent) } else { - println("No apps handling image search") // Fall back to just opening the gallery val photosIntent = IntentUtils.photosApp() startActivity(photosIntent) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt index 7f46667ab..996baa66e 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt @@ -5,6 +5,7 @@ import com.getcode.navigation.NonDismissableRoute import com.getcode.navigation.NonDraggableRoute import com.getcode.navigation.flow.FlowStep import com.getcode.opencode.internal.solana.model.SwapId +import com.getcode.opencode.model.financial.Fiat import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -12,12 +13,19 @@ import kotlinx.serialization.Serializable sealed interface SwapStep : FlowStep, Parcelable { @Parcelize @Serializable - data class Entry(val purpose: SwapPurpose) : SwapStep + data class Entry(val purpose: SwapPurpose, val initialAmount: Fiat? = null) : SwapStep @Parcelize @Serializable data object SellReceipt : SwapStep + @Parcelize + @Serializable + data object PhantomConnect: SwapStep + + @Parcelize + @Serializable + data object PhantomConfirmTransaction: SwapStep @Parcelize @Serializable data class Processing( diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt index ffeb5a287..ec5ad881b 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt @@ -14,12 +14,21 @@ import kotlinx.serialization.Serializable * @see Buy Represents a purchase intent (e.g., swapping base currency for a specific token). * @see Sell Represents a liquidation intent (e.g., swapping a specific token back to base currency). */ +@Serializable +enum class FundingSource { + Flexible, + Phantom, +} + @Serializable @Parcelize sealed interface SwapPurpose : Parcelable { + val mint: Mint sealed interface BalanceIncrease sealed interface BalanceDecrease - @Serializable data class Buy(val mint: Mint) : SwapPurpose, BalanceIncrease - @Serializable data class FundWithWallet(val mint: Mint): SwapPurpose, BalanceIncrease - @Serializable data class Sell(val mint: Mint) : SwapPurpose, BalanceDecrease + @Serializable data class Buy( + override val mint: Mint, + val fundingSource: FundingSource = FundingSource.Flexible, + ) : SwapPurpose, BalanceIncrease + @Serializable data class Sell(override val mint: Mint) : SwapPurpose, BalanceDecrease } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt index 49c6b7ffb..07e93d5d6 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt @@ -1,13 +1,11 @@ package com.flipcash.app.core.verification.email import com.flipcash.app.core.AppRoute -import com.getcode.ed25519.Ed25519 +import com.flipcash.app.core.tokens.SwapPurpose import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.utils.base64 import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 -import com.getcode.utils.base58 -import com.getcode.utils.decodeBase58 import com.getcode.utils.decodeBase64 import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -24,7 +22,10 @@ sealed class EmailDeeplinkOrigin { is OnRamp -> { val amountString = amount?.let { Json.encodeToString(Fiat.Companion.serializer(), it) } when (source) { - is AppRoute.Token.OnRamp -> "onramp|amountentry|${source.mint.base58()}" + is AppRoute.Token.Swap -> { + val mint = (source.purpose as? SwapPurpose.Buy)?.mint + "onramp|amountentry|${mint?.base58()}" + } else -> "onramp|null|$amountString" } } @@ -36,12 +37,8 @@ sealed class EmailDeeplinkOrigin { companion object { fun fromRoute(route: AppRoute?): EmailDeeplinkOrigin? { return when (route) { - is AppRoute.Token.OnRamp -> { - OnRamp(route) - } - + is AppRoute.Token.Swap -> OnRamp(route) is AppRoute.Menu.MyAccount -> MyAccount - else -> null } } @@ -53,18 +50,10 @@ sealed class EmailDeeplinkOrigin { val source = when (splits[1]) { "menu" -> AppRoute.Sheets.Menu "amountentry" -> { - println("deeplink origin amountentry") - val mint = splits.getOrNull(2)?.let { - println("deeplink mint = $it") - Mint(it) - } - - if (mint == null) { - println("deeplink mint is null") - return null - } + val mint = splits.getOrNull(2)?.let { Mint(it) } + ?: return null - AppRoute.Token.OnRamp(mint) + AppRoute.Token.Swap(SwapPurpose.Buy(mint)) } else -> null } diff --git a/apps/flipcash/core/src/main/res/drawable/ic_phantom_buy_connect.xml b/apps/flipcash/core/src/main/res/drawable/ic_phantom_buy_connect.xml new file mode 100644 index 000000000..32c3d7f43 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_phantom_buy_connect.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_phantom_connected.xml b/apps/flipcash/core/src/main/res/drawable/ic_phantom_connected.xml new file mode 100644 index 000000000..038a54c32 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_phantom_connected.xml @@ -0,0 +1,35 @@ + + + + + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 0767617d3..303a97d5d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -332,12 +332,14 @@ Backpack Wallet Phantom Wallet Solflare Wallet + Other Wallet Add Cash with Google Pay Add Cash with Debit Card Add Cash with Credit Card Confirm In Phantom + Solflare Backpack @@ -439,6 +441,7 @@ Amount to Buy Amount to Sell Enter up to %1$s + You must buy at least %1$s You can only buy up to %1$s You can only sell up to %1$s Buy @@ -518,6 +521,9 @@ Account User Flags + Purchase + Connect Your Phantom Wallet + $5 Minimum Purchase Please enter an amount of $5 or higher @@ -640,4 +646,13 @@ Leaderboard Discover + + Buy With Phantom + Purchase using Solana USDC in Phantom. Simply connect your wallet and confirm the transaction + + Confirmation + Connected + Confirm the transaction in Phantom to continue + + Deposit %1$s \ No newline at end of file diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt index 4e55cb19c..3964ff26d 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt @@ -3,6 +3,7 @@ package com.flipcash.app.cash.internal import androidx.lifecycle.viewModelScope import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.ui.CurrencyHolder import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.cash.R @@ -338,12 +339,12 @@ internal class CashScreenViewModel @Inject constructor( .filterIsInstance() .map { it.amount } .onEach { shortfall -> - // route to buy the token - println("shortfall=$shortfall") + // route directly to the swap amount screen, skipping token info + val mint = stateFlow.value.selectedTokenAddress!! dispatchEvent( Event.OpenScreen( - AppRoute.Token.Info( - mint = stateFlow.value.selectedTokenAddress!!, + AppRoute.Token.Swap( + purpose = SwapPurpose.Buy(mint), shortfall = shortfall, ), ) diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt index 84312f454..01e73af71 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt @@ -6,30 +6,26 @@ import androidx.core.text.trimmedLength import androidx.lifecycle.viewModelScope import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.extensions.flatMapResult +import com.flipcash.app.core.extensions.onResult +import com.flipcash.app.core.tokens.CurrencyCreatorDraft import com.flipcash.app.core.tokens.CurrencyCreatorStep import com.flipcash.app.currencycreator.CurrencyCreatorCoordinator import com.flipcash.app.currencycreator.internal.components.CurrencyCreatorTopBarController import com.flipcash.app.onramp.ExternalWalletOnRampController import com.flipcash.app.onramp.ExternalWalletOnRampState -import com.flipcash.app.core.tokens.CurrencyCreatorDraft -import com.flipcash.app.tokens.BalancePoller -import com.flipcash.app.userflags.UserFlagsCoordinator -import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.services.internal.model.thirdparty.OnRampProvider -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.opencode.model.financial.toFiat -import com.getcode.opencode.model.ui.TokenBillCustomizations -import com.flipcash.app.core.data.Loadable -import com.flipcash.app.core.extensions.flatMapResult -import com.flipcash.app.core.extensions.onResult import com.flipcash.app.payments.PaymentAction import com.flipcash.app.payments.PurchaseMethod import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.payments.PurchaseMethodMetadata +import com.flipcash.app.tokens.BalancePoller import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.currencycreator.R +import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ModerationController +import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.models.ImageModerationError import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.TextModerationError @@ -37,7 +33,6 @@ import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.CurrencyController import com.getcode.opencode.controllers.TransactionController -import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.core.errors.CheckTokenAvailabilityError @@ -45,25 +40,28 @@ import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError import com.getcode.opencode.model.core.errors.GetMintsError import com.getcode.opencode.model.core.errors.LaunchTokenError import com.getcode.opencode.model.core.errors.ValidationException +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenCreateRequest import com.getcode.opencode.model.financial.fromLaunch -import com.getcode.opencode.model.financial.minus import com.getcode.opencode.model.financial.orZero import com.getcode.opencode.model.financial.plus +import com.getcode.opencode.model.financial.toFiat import com.getcode.opencode.model.financial.usdf import com.getcode.opencode.model.moderation.ModerationAttestation import com.getcode.opencode.model.transactions.SwapFundingSource +import com.getcode.opencode.model.ui.TokenBillCustomizations import com.getcode.solana.keys.Mint import com.getcode.util.resources.ContentReader import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel2 import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -432,7 +430,6 @@ internal class CurrencyCreatorViewModel @Inject constructor( eventFlow .filterIsInstance() .map { event -> - println("customizations=${stateFlow.value.customizations}") val request = TokenCreateRequest( name = ModerationAttestation.Text( text = stateFlow.value.nameFieldState.text.trim().toString(), @@ -499,6 +496,10 @@ internal class CurrencyCreatorViewModel @Inject constructor( PurchaseMethod.CoinbaseOnRamp -> { dispatchEvent(Event.PurchaseWithGooglePay(ctx)) } + + PurchaseMethod.OtherWallet -> { + // TODO: + } } }, onError = { diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ProcessingScreen.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ProcessingScreen.kt index 137ebc689..8b97ff2b7 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ProcessingScreen.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ProcessingScreen.kt @@ -6,15 +6,14 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.tokens.CurrencyCreatorResult import com.flipcash.app.core.tokens.CurrencyCreatorStep import com.flipcash.app.core.ui.processing.FlowProcessingScreen -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.currencycreator.internal.CurrencyCreatorViewModel import com.flipcash.core.R import com.getcode.navigation.flow.flowSharedViewModel @@ -24,7 +23,6 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.view.LoadingSuccessState -import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.minutes @Composable diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt index f06e825c6..0a5cddcf1 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt @@ -4,10 +4,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.deposit.internal.DepositScreen import com.flipcash.app.deposit.internal.DepositViewModel import com.flipcash.core.R @@ -19,13 +21,13 @@ import com.getcode.ui.components.AppBarWithTitle fun DepositScreen(mint: Mint) { val navigator = LocalCodeNavigator.current val viewModel = hiltViewModel() - + val state by viewModel.stateFlow.collectAsStateWithLifecycle() Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { AppBarWithTitle( - title = stringResource(R.string.title_onrampProviderManualDeposit), + title = stringResource(R.string.title_depositToken, state.tokenName.orEmpty()), isInModal = true, titleAlignment = Alignment.CenterHorizontally, backButton = true, diff --git a/apps/flipcash/features/onramp/.gitignore b/apps/flipcash/features/onramp/.gitignore deleted file mode 100644 index 9f2a07880..000000000 --- a/apps/flipcash/features/onramp/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -build/ -.gradle/ diff --git a/apps/flipcash/features/onramp/build.gradle.kts b/apps/flipcash/features/onramp/build.gradle.kts deleted file mode 100644 index 5f5f1d8eb..000000000 --- a/apps/flipcash/features/onramp/build.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -plugins { - alias(libs.plugins.flipcash.android.feature) -} - -android { - namespace = "${Gradle.flipcashNamespace}.features.onramp" - defaultConfig { - buildConfigField("String", "COINBASE_ONRAMP_API_KEY", "\"${tryReadProperty(rootProject.rootDir, "COINBASE_ONRAMP_API_KEY")}\"") - } -} - -dependencies { - implementation(libs.compose.webview) - - implementation(libs.androidx.localbroadcastmanager) - - implementation(libs.bundles.kotlinx.serialization) - - implementation(project(":apps:flipcash:shared:onramp:coinbase")) - implementation(project(":apps:flipcash:shared:onramp:deeplinks")) - implementation(project(":apps:flipcash:shared:router")) - - implementation(project(":libs:crypto:solana")) - implementation(project(":libs:datetime")) - implementation(project(":libs:messaging")) -} diff --git a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt deleted file mode 100644 index 7e2de365d..000000000 --- a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.flipcash.app.onramp - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.flipcash.app.core.AppRoute -import com.getcode.solana.keys.Mint - -import com.flipcash.app.onramp.internal.OnRampViewModel -import com.flipcash.app.onramp.internal.screens.OnRampAmountScreen -import com.flipcash.features.onramp.R -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.ui.components.AppBarWithTitle -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach - -@Composable -fun OnRampCustomAmountScreen(mint: Mint) { - val navigator = LocalCodeNavigator.current - val viewModel = hiltViewModel() - - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - Column( - modifier = Modifier.fillMaxSize(), - ) { - AppBarWithTitle( - title = stringResource(R.string.title_amountToBuy), - isInModal = true, - backButton = state.orderLookup.isIdle, - onBackIconClicked = { navigator.pop() }, - titleAlignment = Alignment.CenterHorizontally, - ) - OnRampAmountScreen(viewModel) - } - - LaunchedEffect(Unit) { - if (mint != null) { - viewModel.dispatchEvent(OnRampViewModel.Event.OnMintChanged(mint)) - } - } - - val externalWalletOnRampController = LocalExternalWalletOnRampController.current - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.amount } - .onEach { externalWalletOnRampController.setAmount(it) } - .launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { (phone, email) -> - navigator.push( - AppRoute.Verification( - origin = AppRoute.Token.OnRamp(mint), - includePhone = phone, - includeEmail = email, - ) - ) - }.launchIn(this) - } - - val coinbaseOnRampController = LocalCoinbaseOnRampController.current - LaunchedEffect(Unit) { - coinbaseOnRampController.pendingNavigation.collect { route -> - navigator.push(route) - } - } -} diff --git a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt deleted file mode 100644 index d3132ad3f..000000000 --- a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt +++ /dev/null @@ -1,489 +0,0 @@ -package com.flipcash.app.onramp.internal - -import androidx.lifecycle.viewModelScope -import com.flipcash.app.core.extensions.mapResult -import com.flipcash.app.core.extensions.onResult -import com.flipcash.app.core.ui.CurrencyHolder -import com.flipcash.app.onramp.CoinbaseOnRampState -import com.flipcash.app.onramp.OnRampAuthError -import com.flipcash.app.onramp.CoinbaseOnRampController -import com.flipcash.app.onramp.OnRampPaymentError -import com.flipcash.features.onramp.R -import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.services.internal.model.thirdparty.OnRampProvider -import com.flipcash.services.internal.model.thirdparty.OnRampType -import com.getcode.manager.BottomBarManager -import com.getcode.opencode.controllers.TokenController -import com.getcode.opencode.controllers.TransactionOperations -import com.getcode.opencode.exchange.Exchange -import com.getcode.opencode.exchange.VerifiedFiat -import com.getcode.opencode.exchange.VerifiedFiatCalculator -import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError -import com.getcode.opencode.model.financial.Currency -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.Limits -import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.opencode.model.financial.SendLimit -import com.getcode.opencode.model.financial.Token -import com.getcode.opencode.model.financial.toFiat -import com.getcode.opencode.model.financial.usdf -import com.getcode.solana.keys.Mint -import com.getcode.ui.components.text.AmountAnimatedInputUiModel -import com.getcode.ui.components.text.NumberInputHelper -import com.getcode.util.resources.ResourceHelper -import com.getcode.view.BaseViewModel2 -import com.getcode.view.LoadingSuccessState -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject - -internal data class AmountEntryState( - val limits: Limits? = null, - val maxToAdd: Pair? = null, - val currencyModel: CurrencyHolder = CurrencyHolder(), - val amountAnimatedModel: AmountAnimatedInputUiModel = AmountAnimatedInputUiModel(), - val confirmingAmount: LoadingSuccessState = LoadingSuccessState(), - val selectedAmount: VerifiedFiat = VerifiedFiat(LocalFiat.Zero, null), -) { - val canAdd: Boolean - get() = (amountAnimatedModel.amountData.amount) > 0.00 - - val maxAvailableToAdd: String - get() = maxToAdd?.let { Fiat(it.first, it.second).formatted() }.orEmpty() - - val isError: Boolean - get() { - if (amountAnimatedModel.amountData.isEmpty()) return false - - if (maxToAdd != null) { - if ((amountAnimatedModel.amountData.amount) <= maxToAdd.first - ) { - return false - } - } - - return true - } -} - -@HiltViewModel -internal class OnRampViewModel @Inject constructor( - private val exchange: Exchange, - private val verifiedFiatCalculator: VerifiedFiatCalculator, - private val resources: ResourceHelper, - private val onRampController: CoinbaseOnRampController, - tokenController: TokenController, - transactionController: TransactionOperations, - dispatchers: DispatcherProvider, -) : BaseViewModel2( - initialState = State(), - updateStateForEvent = updateStateForEvent, - defaultDispatcher = dispatchers.Default, -) { - - private val numberInputHelper = NumberInputHelper() - - data class State( - val loading: Boolean = false, - val mint: Mint? = null, - val token: Token? = null, - val canChangeCurrency: Boolean = false, - val hasVerifiedPhone: Boolean = false, - val hasVerifiedEmail: Boolean = false, - val selectedProvider: OnRampProvider.ThirdParty? = null, - val amountEntryState: AmountEntryState = AmountEntryState(), - val orderLookup: LoadingSuccessState = LoadingSuccessState(), - ) { - val minimumPurchaseAmount = 5.toFiat() - } - - sealed interface Event { - data class OnMintChanged(val mint: Mint) : Event - data class OnTokenChanged(val token: Token) : Event - - data class OnPhoneVerificationChanged(val verified: Boolean) : Event - data class OnEmailVerificationChanged(val verified: Boolean) : Event - - data class OnProviderSelected(val item: OnRampProvider) : Event - - data class OnVerificationNeeded(val phone: Boolean = false, val email: Boolean = false) : - Event - - // region amount entry events - data class OnMaxDetermined(val max: Double, val currencyCode: CurrencyCode) : Event - data class OnLimitsChanged(val limits: Limits?) : Event - - data class OnNumberPressed(val number: Int) : Event - data object OnDecimalPressed : Event - data object OnBackspace : Event - data class OnEnteredNumberChanged(val backspace: Boolean = false) : Event - data class OnAmountChanged(val amountAnimatedModel: AmountAnimatedInputUiModel) : Event - - data class OnCurrencyChanged(val currency: Currency) : Event - - data object OnAmountConfirmed : Event - data class UpdateConfirmingAmountState( - val loading: Boolean = false, - val success: Boolean = false - ) : Event - - data class UpdateOrderLookupState( - val loading: Boolean = false, - val success: Boolean = false - ): Event - - data class OnAmountAccepted(val amount: VerifiedFiat) : Event - - data class CreateAndSendTransactionToWallet(val amount: VerifiedFiat) : Event - // endregion - } - - val checkFundingAmount: () -> Boolean = { - val amount = stateFlow.value.amountEntryState.amountAnimatedModel.amountData.amount - val currency = stateFlow.value.amountEntryState.currencyModel - val sendLimit = - currency.code?.let { stateFlow.value.amountEntryState.limits?.sendLimitFor(it) } - ?: SendLimit.Zero - val isOverLimit = amount > sendLimit.maxPerDay - if (isOverLimit) { - BottomBarManager.showAlert( - resources.getString(R.string.error_title_insufficientFunds), - resources.getString(R.string.error_description_insufficientFunds) - ) - } - isOverLimit - } - - init { - numberInputHelper.reset() - - onRampController.state - .onEach { s -> - when (s) { - is CoinbaseOnRampState.Completed -> dispatchEvent(Event.UpdateOrderLookupState(success = true)) - is CoinbaseOnRampState.Failed -> { - dispatchEvent(Event.UpdateConfirmingAmountState()) - dispatchEvent(Event.UpdateOrderLookupState()) - } - CoinbaseOnRampState.Idle -> { - dispatchEvent(Event.UpdateConfirmingAmountState()) - dispatchEvent(Event.UpdateOrderLookupState()) - } - is CoinbaseOnRampState.Paying -> dispatchEvent(Event.UpdateOrderLookupState(loading = true)) - } - } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { it.mint } - .map { tokenController.getTokenMetadata(it) } - .mapResult { it.token } - .onResult( - onSuccess = { - dispatchEvent(Event.OnTokenChanged(it)) - } - ).launchIn(viewModelScope) - - dispatchEvent(Event.OnProviderSelected(OnRampProvider.Coinbase(OnRampType.Virtual))) - - exchange.observeEntryRate() - .mapNotNull { - exchange.getCurrency(it.currency.name) - }.onEach { - dispatchEvent(Event.OnCurrencyChanged(it)) - }.launchIn(viewModelScope) - - exchange.observeEntryRate() - .onEach { - // reset when entry rate changes - numberInputHelper.reset() - dispatchEvent(Event.OnAmountChanged(AmountAnimatedInputUiModel())) - }.launchIn(viewModelScope) - - transactionController.limits - .onEach { dispatchEvent(Event.OnLimitsChanged(it)) } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { it.currency } - .onEach { - numberInputHelper.fractionUnits = it.fractionUnits - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { it.number } - .onEach { number -> - numberInputHelper.fractionUnits = - stateFlow.value.amountEntryState.currencyModel.fractionUnits - numberInputHelper.maxLength = 10 // 1 billion dollars - numberInputHelper.onNumber(number) - dispatchEvent(Event.OnEnteredNumberChanged()) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - numberInputHelper.onDot() - dispatchEvent(Event.OnEnteredNumberChanged()) - } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - numberInputHelper.onBackspace() - dispatchEvent(Event.OnEnteredNumberChanged(true)) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { it.backspace } - .onEach { backspace -> - val current = stateFlow.value.amountEntryState.amountAnimatedModel - val model = stateFlow.value.amountEntryState.amountAnimatedModel - val amount = numberInputHelper.getFormattedStringForAnimation(includeCommas = true) - - val updated = model.copy( - amountDataLast = current.amountData, - amountData = amount, - lastPressedBackspace = backspace - ) - - dispatchEvent(Event.OnAmountChanged(updated)) - }.launchIn(viewModelScope) - - stateFlow - .map { it.amountEntryState } - .filter { it.limits != null } - .map { it.limits to it.currencyModel.code } - .mapNotNull { - val currency = it.second ?: return@mapNotNull null - it.first to currency - } - .onEach { (limits, currency) -> - val sendLimit = limits?.sendLimitFor(currency) ?: SendLimit.Zero - val nextTransactionLimit = sendLimit.maxPerDay - dispatchEvent(Event.OnMaxDetermined(nextTransactionLimit, currency)) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { stateFlow.value.amountEntryState.amountAnimatedModel } - .filter { !(checkFundingAmount()) } - .onEach { data -> - dispatchEvent(Event.UpdateConfirmingAmountState(loading = true)) - val rate = exchange.rateFor( - stateFlow.value.amountEntryState.currencyModel.code ?: CurrencyCode.USD - ) ?: exchange.entryRate - - val localizedAmount = Fiat(data.amountData.amount, rate.currency) - - if (stateFlow.value.selectedProvider is OnRampProvider.Coinbase) { - if (localizedAmount < stateFlow.value.minimumPurchaseAmount) { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_onrampAmountTooLow), - message = resources.getString(R.string.error_description_onrampAmountTooLow) - ) - dispatchEvent(Event.UpdateConfirmingAmountState()) - return@onEach - } - } - - val amountFiat = verifiedFiatCalculator.compute( - amount = localizedAmount, - token = Token.usdf, - rate = rate, - ).getOrElse { cause -> - dispatchEvent(Event.UpdateConfirmingAmountState()) - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_staleRates), - message = resources.getString(R.string.error_description_staleRates), - ) - return@onEach - } - - dispatchEvent(Event.OnAmountAccepted(amountFiat)) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { it.item } - // we are locking deeplink transfers and onramp buys to USD - .filter { it is OnRampProvider.UsesDeeplinks || it is OnRampProvider.Coinbase } - .mapNotNull { exchange.getCurrency(CurrencyCode.USD.name) } - .onEach { dispatchEvent(Event.OnCurrencyChanged(it)) } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { - val provider = stateFlow.value.selectedProvider ?: return@mapNotNull null - it.amount to provider - } - .onEach { (selectedAmount, provider) -> - when (provider) { - is OnRampProvider.Coinbase -> { - when (provider.type) { - OnRampType.Virtual -> { - val token = stateFlow.value.token - if (token == null) { - dispatchEvent(Event.UpdateConfirmingAmountState()) - return@onEach - } - - onRampController.placeOrderAndStartPayment( - amount = selectedAmount.localFiat.underlyingTokenAmount, - token = token, - verifiedFiat = selectedAmount, - ).onFailure { error -> - dispatchEvent(Event.UpdateConfirmingAmountState()) - when (error) { - is OnRampAuthError.CoinbasePhoneVerificationRequired -> { - dispatchEvent(Event.OnVerificationNeeded(phone = true)) - } - - is OnRampAuthError.VerificationRequired -> { - dispatchEvent( - Event.OnVerificationNeeded( - phone = error.phone, - email = error.email - ) - ) - } - - is OnRampPaymentError.GooglePayNotSupported -> { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_onrampGooglePayNotSupported), - message = resources.getString(R.string.error_description_onrampGooglePayNotSupported), - ) - } - - is OnRampPaymentError.GooglePayNoPaymentMethod -> { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_onrampGooglePayNotReady), - message = resources.getString(R.string.error_description_onrampGooglePayNotReady), - ) - } - - else -> { - BottomBarManager.showError( - title = "Something Went Wrong", - message = error.message ?: "Please try again", - ) - } - } - } - } - - else -> Unit - } - } - - is OnRampProvider.UsesDeeplinks -> { - dispatchEvent(Event.CreateAndSendTransactionToWallet(selectedAmount)) - } - } - }.launchIn(viewModelScope) - } - - override fun onCleared() { - exchange.resetEntryToBalance() - } - - internal companion object { - val updateStateForEvent: (Event) -> ((State) -> State) = { event -> - when (event) { - is Event.OnMintChanged -> { state -> state.copy(mint = event.mint) } - is Event.OnTokenChanged -> { state -> state.copy(token = event.token) } - is Event.OnProviderSelected -> { state -> - state.copy( - canChangeCurrency = event.item !is OnRampProvider.Phantom && event.item !is OnRampProvider.Coinbase, - selectedProvider = event.item as? OnRampProvider.ThirdParty - ) - } - - is Event.OnPhoneVerificationChanged -> { state -> state.copy(hasVerifiedPhone = event.verified) } - is Event.OnEmailVerificationChanged -> { state -> state.copy(hasVerifiedEmail = event.verified) } - - is Event.OnAmountAccepted -> { state -> - state.copy( - amountEntryState = state.amountEntryState.copy( - selectedAmount = event.amount - ) - ) - } - - is Event.OnAmountChanged -> { state -> - state.copy(amountEntryState = state.amountEntryState.copy(amountAnimatedModel = event.amountAnimatedModel)) - } - - is Event.OnCurrencyChanged -> { state -> - state.copy( - amountEntryState = state.amountEntryState.copy( - currencyModel = CurrencyHolder( - event.currency - ) - ) - ) - } - - is Event.OnLimitsChanged -> { state -> - state.copy( - amountEntryState = state.amountEntryState.copy( - limits = event.limits - - ) - ) - } - - is Event.OnMaxDetermined -> { state -> - state.copy( - amountEntryState = state.amountEntryState.copy( - maxToAdd = event.max to event.currencyCode - ), - ) - } - - is Event.UpdateConfirmingAmountState -> { state -> - val entryState = state.amountEntryState - val loadingSuccess = entryState.confirmingAmount - state.copy( - amountEntryState = entryState.copy( - confirmingAmount = loadingSuccess.copy( - loading = event.loading, - success = event.success - ) - ) - ) - } - - is Event.UpdateOrderLookupState -> { state -> - val lookupState = state.orderLookup - state.copy( - orderLookup = lookupState.copy( - loading = event.loading, - success = event.success, - ) - ) - } - - is Event.OnVerificationNeeded, - is Event.CreateAndSendTransactionToWallet, - Event.OnAmountConfirmed, - Event.OnBackspace, - is Event.OnEnteredNumberChanged, - is Event.OnNumberPressed, - Event.OnDecimalPressed -> { state -> state } - } - } - } -} \ No newline at end of file diff --git a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/screens/OnRampAmountScreenContent.kt b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/screens/OnRampAmountScreenContent.kt deleted file mode 100644 index afb9467e0..000000000 --- a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/screens/OnRampAmountScreenContent.kt +++ /dev/null @@ -1,125 +0,0 @@ -package com.flipcash.app.onramp.internal.screens - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.money.RegionSelectionKind -import com.flipcash.app.core.onramp.ui.buildExternalWalletButtonLabel -import com.flipcash.app.core.ui.AmountWithKeypad -import com.flipcash.app.onramp.internal.AmountEntryState -import com.flipcash.app.onramp.internal.OnRampViewModel -import com.flipcash.features.onramp.R -import com.flipcash.services.internal.model.thirdparty.OnRampProvider -import com.flipcash.services.internal.model.thirdparty.OnRampType -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.theme.CodeTheme -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton - -@Composable -internal fun OnRampAmountScreen( - viewModel: OnRampViewModel, -) { - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - OnRampAmountScreenContent( - state = state.amountEntryState, - provider = state.selectedProvider, - canChangeCurrency = state.canChangeCurrency, - dispatchEvent = viewModel::dispatchEvent, - ) -} - -@Composable -private fun OnRampAmountScreenContent( - state: AmountEntryState, - canChangeCurrency: Boolean, - provider: OnRampProvider.ThirdParty?, - dispatchEvent: (OnRampViewModel.Event) -> Unit, -) { - val navigator = LocalCodeNavigator.current - - Column( - modifier = Modifier.fillMaxSize(), - ) { - AmountWithKeypad( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - amountAnimatedModel = state.amountAnimatedModel, - currencyFlag = state.currencyModel.selected?.resId, - prefix = state.currencyModel.selected?.symbol.orEmpty(), - placeholder = "0", - hint = if (state.isError) { - stringResource(R.string.subtitle_onrampPurchaseExceeded, state.maxAvailableToAdd) - } else { - stringResource(R.string.subtitle_onrampPurchaseHint, state.maxAvailableToAdd) - }, - decimalPlaces = state.currencyModel.fractionUnits, - isClickable = canChangeCurrency, - onAmountClicked = { - navigator.push( - AppRoute.Main.RegionSelection( - kind = RegionSelectionKind.Entry - ) - ) - }, - isError = state.isError, - onNumberPressed = { dispatchEvent(OnRampViewModel.Event.OnNumberPressed(it)) }, - onBackspace = { dispatchEvent(OnRampViewModel.Event.OnBackspace) }, - onDecimal = { dispatchEvent(OnRampViewModel.Event.OnDecimalPressed) } - ) - - ConfirmationButton( - modifier = Modifier - .fillMaxWidth(), - state = state, - provider = provider, - dispatchEvent = dispatchEvent - ) - } -} - -@Composable -private fun ConfirmationButton( - state: AmountEntryState, - provider: OnRampProvider.ThirdParty?, - modifier: Modifier = Modifier, - dispatchEvent: (OnRampViewModel.Event) -> Unit -) { - val (buttonText, assets) = when (provider) { - is OnRampProvider.Coinbase -> AnnotatedString(stringResource(R.string.action_buy)) to emptyMap() - is OnRampProvider.UsesDeeplinks -> { - buildExternalWalletButtonLabel( - prefix = stringResource(R.string.label_confirmIn), - provider = provider, - isEnabled = state.canAdd - ) - } - - null -> AnnotatedString(stringResource(R.string.action_addCash)) to emptyMap() - } - CodeButton( - enabled = state.canAdd, - modifier = modifier - .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x2) - .navigationBarsPadding(), - buttonState = ButtonState.Filled, - isLoading = state.confirmingAmount.loading, - isSuccess = state.confirmingAmount.success, - text = buttonText, - inlineContent = assets, - ) { - dispatchEvent(OnRampViewModel.Event.OnAmountConfirmed) - } -} diff --git a/apps/flipcash/features/tokens/build.gradle.kts b/apps/flipcash/features/tokens/build.gradle.kts index 95d15b6e2..0ead1b8c7 100644 --- a/apps/flipcash/features/tokens/build.gradle.kts +++ b/apps/flipcash/features/tokens/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(libs.bundles.haze) implementation(project(":apps:flipcash:shared:analytics")) + implementation(project(":apps:flipcash:shared:onramp:coinbase")) implementation(project(":apps:flipcash:shared:onramp:deeplinks")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tokens")) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt new file mode 100644 index 000000000..b4759fae8 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt @@ -0,0 +1,244 @@ +package com.flipcash.app.tokens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.onramp.ui.buildPhantomButtonLabel +import com.flipcash.app.core.tokens.SwapResult +import com.flipcash.app.core.tokens.SwapStep +import com.flipcash.app.onramp.LocalExternalWalletOnRampController +import com.flipcash.app.tokens.ui.SwapViewModel +import com.flipcash.core.R +import com.flipcash.services.internal.model.thirdparty.OnRampProvider +import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.core.rememberAnimationScale +import com.getcode.ui.core.scaled +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Composable +internal fun PhantomConnectConfirmationScreen() { + val flowNavigator = rememberFlowNavigator() + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val externalWalletOnRampController = LocalExternalWalletOnRampController.current + + val animationScale by rememberAnimationScale() + LaunchedEffect(Unit) { + externalWalletOnRampController.pendingNavigation.collect { nav -> + if (nav is AppRoute.Token.Swap) { + delay(300.scaled(animationScale)) + flowNavigator.navigateTo(SwapStep.PhantomConfirmTransaction) + } + } + } + + LaunchedEffect(Unit) { + externalWalletOnRampController.flowExitRequests.collect { + flowNavigator.exitCanceled() + } + } + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_purchase), + isInModal = true, + backButton = true, + onBackIconClicked = { flowNavigator.back() }, + titleAlignment = Alignment.CenterHorizontally, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .navigationBarsPadding(), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_connectYourPhantomWallet), + ) { + val mint = state.purpose?.mint + mint?.let { + externalWalletOnRampController.start( + AppRoute.Token.Info(mint = it), + OnRampProvider.Phantom, + ) + } + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x11), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_phantom_buy_connect), + contentDescription = null, + ) + } + + Column( + modifier = Modifier.fillMaxWidth(0.60f), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.title_buyWithPhantom), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.description_buyWithPhantom), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Composable +internal fun PhantomTransactionConfirmationScreen() { + val flowNavigator = rememberFlowNavigator() + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val externalWalletOnRampController = LocalExternalWalletOnRampController.current + + LaunchedEffect(Unit) { + externalWalletOnRampController.pendingNavigation.collect { nav -> + if (nav is AppRoute.Token.TxProcessing) { + flowNavigator.navigateTo( + SwapStep.Processing(nav.swapId, nav.awaitExternalWallet) + ) + } + } + } + + LaunchedEffect(Unit) { + externalWalletOnRampController.flowExitRequests.collect { + flowNavigator.exitCanceled() + } + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { (token, amount) -> + externalWalletOnRampController.setTokenToPurchase(token) + externalWalletOnRampController.setAmount(amount) + }.launchIn(this) + } + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_purchase), + isInModal = true, + backButton = true, + onBackIconClicked = { flowNavigator.back() }, + titleAlignment = Alignment.CenterHorizontally, + ) + }, + bottomBar = { + val label = buildPhantomButtonLabel( + prefix = stringResource(com.flipcash.features.tokens.R.string.label_confirmIn), + isEnabled = state.canTransact + ) + + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .navigationBarsPadding(), + buttonState = ButtonState.Filled, + text = label.first, + inlineContent = label.second, + isLoading = state.buyProgress.loading, + enabled = state.buyProgress.isIdle, + ) { + viewModel.dispatchEvent(SwapViewModel.Event.ConfirmPhantomTransaction) + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x11), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_phantom_connected), + contentDescription = null, + ) + } + + Column( + modifier = Modifier.fillMaxWidth(0.80f), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.title_buyWithPhantomConnected), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.description_buyWithPhantomConnected), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryContent.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt similarity index 65% rename from apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryContent.kt rename to apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index dc766309d..704a3b476 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryContent.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -13,26 +13,35 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.SwapStep +import com.flipcash.app.onramp.LocalCoinbaseOnRampController import com.flipcash.app.onramp.LocalExternalWalletOnRampController import com.flipcash.app.tokens.internal.SwapEntryScreenContent import com.flipcash.app.tokens.ui.SwapViewModel import com.flipcash.features.tokens.R +import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.opencode.model.financial.Fiat import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.core.rememberAnimationScale +import com.getcode.ui.core.scaled +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @Composable -internal fun SwapEntryContent( +internal fun SwapEntryScreen( purpose: SwapPurpose, + initialAmount: Fiat? = null, ) { val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current val externalWalletOnRampController = LocalExternalWalletOnRampController.current + val coinbaseOnRampController = LocalCoinbaseOnRampController.current Column( modifier = Modifier.fillMaxSize(), @@ -60,6 +69,9 @@ internal fun SwapEntryContent( LaunchedEffect(viewModel) { viewModel.dispatchEvent(SwapViewModel.Event.OnPurposeChanged(purpose)) + if (initialAmount != null) { + viewModel.dispatchEvent(SwapViewModel.Event.OnInitialAmountProvided(initialAmount)) + } } LaunchedEffect(viewModel) { @@ -106,4 +118,45 @@ internal fun SwapEntryContent( } } } + + LaunchedEffect(Unit) { + externalWalletOnRampController.flowExitRequests.collect { + flowNavigator.exitCanceled() + } + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { (phone, email) -> + val mint = (viewModel.stateFlow.value.purpose as? SwapPurpose.Buy)?.mint ?: return@onEach + navigator.push( + AppRoute.Verification( + origin = AppRoute.Token.Swap(SwapPurpose.Buy(mint)), + includePhone = phone, + includeEmail = email, + ) + ) + }.launchIn(this) + } + + val animationScale by rememberAnimationScale() + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { delay(300.scaled(animationScale)) } + .onEach { + flowNavigator.navigateTo(SwapStep.PhantomConnect) + }.launchIn(this) + } + + LaunchedEffect(Unit) { + coinbaseOnRampController.pendingNavigation.collect { route -> + if (route is AppRoute.Token.TxProcessing) { + flowNavigator.navigateTo( + SwapStep.Processing(route.swapId, route.awaitExternalWallet) + ) + } + } + } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt index 9c2e975ce..7c2a0eb57 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt @@ -52,10 +52,16 @@ fun SwapFlowScreen( private fun swapEntryProvider(): (NavKey) -> NavEntry = entryProvider { flowAnnotatedEntry { step -> - SwapEntryContent(step.purpose) + SwapEntryScreen(step.purpose, step.initialAmount) + } + annotatedEntry { SellReceiptScreen() } + annotatedEntry { + PhantomConnectConfirmationScreen() + } + annotatedEntry { + PhantomTransactionConfirmationScreen() } - annotatedEntry { SellReceiptContent() } annotatedEntry { step -> - SwapProcessingContent(step.swapId, step.awaitExternalWallet) + SwapProcessingScreen(step.swapId, step.awaitExternalWallet) } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index cb592d677..359d8c27b 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -115,25 +115,5 @@ fun TokenInfoScreen( navigator.push(it) }.launchIn(this) } - - val animationScale by rememberAnimationScale() - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { delay(300.scaled(animationScale)) } - .onEach { - externalWalletOnRampController.start(AppRoute.Token.Info(mint), OnRampProvider.Phantom) - }.launchIn(this) - } - - // Navigate to pending routes from ExternalWalletOnRampHandler using the - // sheet's inner navigator (which the handler can't access directly). - LaunchedEffect(Unit) { - externalWalletOnRampController.pendingNavigation.collect { nav -> - if (nav is AppRoute.Token.Swap) { - navigator.push(nav) - } - } - } } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt index b36f6b661..d8250c310 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @Composable -internal fun SellReceiptContent() { +internal fun SellReceiptScreen() { val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt index 8756a3fe2..4c8c597a5 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.flow.onEach * Flow-aware swap processing content, used inside `SwapFlowScreen`. */ @Composable -internal fun SwapProcessingContent( +internal fun SwapProcessingScreen( swapId: SwapId, awaitExternalWallet: Boolean = false, ) { diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt index a136dc92f..b1013a923 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -16,6 +17,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.core.money.RegionSelectionKind import com.flipcash.app.core.onramp.ui.buildPhantomButtonLabel +import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.ui.AmountWithKeypad import com.flipcash.app.tokens.ui.SwapViewModel @@ -24,6 +26,8 @@ import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton +import kotlin.collections.emptyMap +import kotlin.to @Composable internal fun SwapEntryScreenContent( @@ -55,7 +59,12 @@ internal fun SwapEntryScreenContent( currencyFlag = entryState.currencyModel.selected?.resId, prefix = entryState.currencyModel.selected?.symbol.orEmpty(), placeholder = "0", - hint = if (state.isError) { + hint = if (state.isBelowMinimum) { + stringResource( + R.string.subtitle_buyHintBelowMinimum, + state.minimumBuyAmount?.formatted().orEmpty() + ) + } else if (state.isError) { when (state.purpose) { is SwapPurpose.BalanceIncrease -> stringResource( R.string.subtitle_buyHintLimitExceeded, @@ -76,7 +85,7 @@ internal fun SwapEntryScreenContent( ) }, decimalPlaces = entryState.currencyModel.fractionUnits, - isClickable = state.purpose !is SwapPurpose.FundWithWallet, + isClickable = (state.purpose as? SwapPurpose.Buy)?.fundingSource != FundingSource.Phantom, onAmountClicked = { navigator.push( AppRoute.Main.RegionSelection( @@ -97,14 +106,10 @@ internal fun SwapEntryScreenContent( ) Box(modifier = Modifier.fillMaxWidth()) { - val (text, inlineContent) = when (state.purpose) { - is SwapPurpose.Buy -> AnnotatedString(stringResource(R.string.action_buy)) to emptyMap() - is SwapPurpose.FundWithWallet -> buildPhantomButtonLabel( - prefix = stringResource(R.string.label_confirmIn), - isEnabled = state.canTransact - ) - is SwapPurpose.Sell -> AnnotatedString(stringResource(R.string.action_next)) to emptyMap() - else -> AnnotatedString("") to emptyMap() + val text = when (state.purpose) { + is SwapPurpose.Buy -> AnnotatedString(stringResource(R.string.action_buy)) + is SwapPurpose.Sell -> AnnotatedString(stringResource(R.string.action_next)) + else -> AnnotatedString("") } CodeButton( @@ -118,7 +123,6 @@ internal fun SwapEntryScreenContent( isLoading = state.buyProgress.loading, isSuccess = state.buyProgress.success, text = text, - inlineContent = inlineContent, ) { dispatchEvent(SwapViewModel.Event.OnAmountConfirmed) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt index 3fc1ba700..4142d13ba 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt @@ -29,9 +29,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.analytics.Action import com.flipcash.app.analytics.Button -import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.data.Loadable @@ -42,7 +40,6 @@ import com.flipcash.app.tokens.internal.components.info.MarketCapSection import com.flipcash.app.tokens.internal.components.info.TokenBalance import com.flipcash.app.tokens.internal.components.info.TokenDetailsSection import com.flipcash.features.tokens.R -import com.getcode.libs.analytics.LocalAnalytics import com.getcode.opencode.model.financial.Fiat import com.getcode.theme.CodeTheme import com.getcode.ui.core.drawWithGradient @@ -291,7 +288,7 @@ private fun BottomBarButtons( buttonState = ButtonState.Filled, text = stringResource(R.string.action_buy), ) { - dispatch(TokenInfoViewModel.Event.OpenPurchaseMethods(shortfall)) + dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) } if (canGive) { diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampController.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampController.kt index 443685b16..fc753077d 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampController.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampController.kt @@ -77,6 +77,9 @@ class ExternalWalletOnRampController @Inject constructor( private val _pendingNavigation = MutableSharedFlow(extraBufferCapacity = 1) val pendingNavigation: SharedFlow = _pendingNavigation.asSharedFlow() + private val _flowExitRequests = MutableSharedFlow(extraBufferCapacity = 1) + val flowExitRequests: SharedFlow = _flowExitRequests.asSharedFlow() + private val _amount = MutableStateFlow(null) val amount: StateFlow = _amount.asStateFlow() @@ -107,6 +110,10 @@ class ExternalWalletOnRampController @Inject constructor( _pendingNavigation.tryEmit(route) } + fun requestFlowExit() { + _flowExitRequests.tryEmit(Unit) + } + fun reset() { _state.value = ExternalWalletOnRampState.Idle _amount.value = null diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt index 7d246c52f..bfb00319a 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt @@ -13,6 +13,7 @@ import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.IntentUtils import com.flipcash.app.core.android.extensions.canNativelyHandle +import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.onramp.internal.buildConnectDeeplink import com.flipcash.app.onramp.internal.buildTransactionDeeplink @@ -50,6 +51,8 @@ fun ExternalWalletOnRampHandler( ?: (state as? ExternalWalletOnRampState.Failed)?.origin if (origin is AppRoute.Token.Info || origin is AppRoute.Token.CurrencyCreator) { + controller.requestFlowExit() + controller.reset() return } @@ -92,7 +95,7 @@ fun ExternalWalletOnRampHandler( if (current.origin is AppRoute.Token.Info) { controller.emitPendingNavigation( AppRoute.Token.Swap( - SwapPurpose.FundWithWallet(current.origin.mint), + SwapPurpose.Buy(current.origin.mint, FundingSource.Phantom), shortfall = current.origin.shortfall ) ) @@ -135,7 +138,11 @@ fun ExternalWalletOnRampHandler( // Amount is always pre-set from CurrencyCreator; no-op for safety } else -> { - navigator.push(AppRoute.Token.OnRamp(controller.tokenToPurchase.value!!.address)) + navigator.push( + AppRoute.Token.Swap( + SwapPurpose.Buy(controller.tokenToPurchase.value!!.address, FundingSource.Phantom) + ) + ) } } } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt index f26337478..6280a1c5a 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt @@ -10,13 +10,15 @@ sealed interface PurchaseMethod { data object CoinbaseOnRamp : PurchaseMethod data class CashReserves(val balance: LocalFiat) : PurchaseMethod data object PhantomWallet : PurchaseMethod + data object OtherWallet: PurchaseMethod } data class PurchaseMethodMetadata( val mint: Mint? = null, val purchaseAmount: Fiat? = null, val feeAmount: Fiat? = null, - val paymentAction: PaymentAction = PaymentAction.Buy + val paymentAction: PaymentAction = PaymentAction.Buy, + val canUseOtherWallets: Boolean = false, ) data class PurchaseMethodSelection( diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt index 14bc98e33..1c64a6b72 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt @@ -7,4 +7,5 @@ interface PurchaseMethodController { val state: StateFlow val selections: Flow fun present(metadata: PurchaseMethodMetadata = PurchaseMethodMetadata()) + fun select(method: PurchaseMethod, metadata: PurchaseMethodMetadata) } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodState.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodState.kt index 3a74dfdd1..190ab74e9 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodState.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodState.kt @@ -7,6 +7,7 @@ data class PurchaseMethodState( val coinbaseOnRampAvailable: Boolean = false, val reservesBalance: LocalFiat = LocalFiat.Zero, val preferredProvider: OnRampProvider.Defined? = null, + val canUseOtherWallets: Boolean = false, ) { val hasReserves: Boolean get() = reservesBalance.underlyingTokenAmount.valueNonZero() @@ -16,5 +17,6 @@ data class PurchaseMethodState( if (coinbaseOnRampAvailable) add(PurchaseMethod.CoinbaseOnRamp) if (hasReserves) add(PurchaseMethod.CashReserves(reservesBalance)) add(PurchaseMethod.PhantomWallet) + if (canUseOtherWallets) add(PurchaseMethod.OtherWallet) } } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt index 8f3c12329..a797dd4de 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt @@ -71,13 +71,22 @@ internal fun purchaseOptions( add( buildButtonAction( - prefix = resources.getString(R.string.label_solanaUsdc), + prefix = null, suffix = resources.getString(R.string.label_phantom), iconRes = R.drawable.ic_phantom_wallet, onClick = { onClick(PurchaseMethod.PhantomWallet) } ) ) + if (state.canUseOtherWallets) { + add( + BottomBarAction( + text = resources.getString(R.string.title_onrampProviderOtherWallet), + onClick = { onClick(PurchaseMethod.OtherWallet) } + ) + ) + } + add( BottomBarAction( text = resources.getString(R.string.action_dismiss), diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt index 41113e257..cabfe5222 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt @@ -2,6 +2,7 @@ package com.flipcash.app.payments.internal import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.payments.PurchaseMethod import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.payments.PurchaseMethodMetadata import com.flipcash.app.payments.PurchaseMethodSelection @@ -17,6 +18,7 @@ import com.getcode.opencode.model.financial.LocalFiat import com.getcode.util.resources.ResourceHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -83,12 +85,20 @@ class InternalPurchaseMethodController @Inject constructor( }.launchIn(scope) } + override fun select(method: PurchaseMethod, metadata: PurchaseMethodMetadata) { + scope.launch { + _selections.emit(PurchaseMethodSelection(method, metadata)) + } + } + override fun present(metadata: PurchaseMethodMetadata) { + _state.update { it.copy(canUseOtherWallets = metadata.canUseOtherWallets) } BottomBarManager.showMessage( title = resources.getString(R.string.prompt_title_selectPurchaseMethod), actions = purchaseOptions(_state.value, metadata, resources) { method -> scope.launch { val selection = PurchaseMethodSelection(method, metadata) + delay(300) _selections.emit(selection) } }, diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 893df2623..9f8157836 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -10,6 +10,7 @@ import com.flipcash.app.core.onramp.deeplinks.ExternalWalletDeeplinkError import com.flipcash.app.core.onramp.deeplinks.OnRampDeeplinkOrigin import com.flipcash.app.core.onramp.deeplinks.WalletDeeplinkConnectionResult import com.flipcash.app.core.onramp.deeplinks.WalletDeeplinkSigningResult +import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.verification.email.EmailDeeplinkOrigin import com.flipcash.app.router.Router import com.flipcash.app.router.internal.AppRouter.Companion.cashLink @@ -83,12 +84,14 @@ internal class AppRouter( val origin = EmailDeeplinkOrigin.deserialize(type.origin.orEmpty()) val routes: List = when (origin) { is EmailDeeplinkOrigin.OnRamp -> when (val source = origin.source) { - is AppRoute.Token.OnRamp -> { + is AppRoute.Token.Swap -> { + val mint = (source.purpose as? SwapPurpose.Buy)?.mint + ?: return DeeplinkAction.None listOf( - AppRoute.Token.Info(source.mint), - AppRoute.Token.OnRamp(source.mint), + AppRoute.Token.Info(mint), + AppRoute.Token.Swap(SwapPurpose.Buy(mint)), ) + AppRoute.Verification( - origin = AppRoute.Token.OnRamp(source.mint), + origin = AppRoute.Token.Swap(SwapPurpose.Buy(mint)), includePhone = false, email = type.email, emailVerificationCode = type.code diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 8b792a5b4..a322b8d45 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -3,11 +3,21 @@ package com.flipcash.app.tokens.ui import androidx.lifecycle.viewModelScope import com.flipcash.app.activityfeed.ActivityFeedCoordinator import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.extensions.to +import com.flipcash.app.core.money.formatted +import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.ui.CurrencyHolder +import com.flipcash.app.onramp.CoinbaseOnRampController +import com.flipcash.app.onramp.CoinbaseOnRampState +import com.flipcash.app.onramp.OnRampAuthError +import com.flipcash.app.onramp.OnRampPaymentError +import com.flipcash.app.payments.PurchaseMethod +import com.flipcash.app.payments.PurchaseMethodController +import com.flipcash.app.payments.PurchaseMethodMetadata import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager @@ -57,6 +67,7 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.math.roundToInt data class AmountEntryState( val limits: Limits? = null, @@ -76,6 +87,8 @@ class SwapViewModel @Inject constructor( private val tokenCoordinator: TokenCoordinator, feedCoordinator: ActivityFeedCoordinator, private val analytics: FlipcashAnalyticsService, + private val purchaseMethodController: PurchaseMethodController, + private val coinbaseOnRampController: CoinbaseOnRampController, dispatchers: DispatcherProvider, ) : BaseViewModel2( initialState = State(), @@ -95,6 +108,8 @@ class SwapViewModel @Inject constructor( val sellProgress: LoadingSuccessState = LoadingSuccessState(), val processingProgress: LoadingSuccessState = LoadingSuccessState(), val confirmedNetTransferAmount: Fiat? = null, + val minimumBuyAmount: Fiat? = null, + val pendingInitialAmount: Fiat? = null, ) { val sellFee: Double? get() { @@ -111,14 +126,9 @@ class SwapViewModel @Inject constructor( val maxAvailableToSwap: String get() = when (purpose) { - is SwapPurpose.Buy -> reservesBalance.formatted() - is SwapPurpose.FundWithWallet -> amountEntryState.maxToAdd?.let { - Fiat( - it.first, - it.second - ).formatted() + is SwapPurpose.Buy -> amountEntryState.maxToAdd?.let { + Fiat(it.first, it.second).formatted() }.orEmpty() - is SwapPurpose.Sell -> tokenBalance.formatted() null -> "" } @@ -150,26 +160,28 @@ class SwapViewModel @Inject constructor( val transactionLimit: Fiat get() { return when (purpose) { - is SwapPurpose.Buy -> reservesBalance - is SwapPurpose.FundWithWallet -> { - val sendLimit = - enteredAmount.currencyCode.let { - amountEntryState.limits?.sendLimitFor( - it - ) - } ?: SendLimit.Zero - + is SwapPurpose.Buy -> { + val sendLimit = enteredAmount.currencyCode.let { + amountEntryState.limits?.sendLimitFor(it) + } ?: SendLimit.Zero sendLimit.maxPerDay.toFiat(enteredAmount.currencyCode) } - is SwapPurpose.Sell -> tokenBalance null -> Fiat.Zero } } + val isBelowMinimum: Boolean + get() { + val min = minimumBuyAmount ?: return false + if (amountEntryState.amountAnimatedModel.amountData.isEmpty()) return false + return enteredAmount.valueLessThan(min) + } + val isError: Boolean get() { if (amountEntryState.amountAnimatedModel.amountData.isEmpty()) return false + if (isBelowMinimum) return true return !enteredAmount.valueLessThanOrEqualTo(transactionLimit) } } @@ -194,6 +206,8 @@ class SwapViewModel @Inject constructor( data class OnCurrencyChanged(val currency: Currency) : Event + data object PhantomSelected: Event + data object ConfirmPhantomTransaction : Event data object OnAmountConfirmed : Event data object OnSellConfirmed : Event @@ -229,6 +243,9 @@ class SwapViewModel @Inject constructor( ) : Event data object OnTransactionSuccessful : Event + data class OnInitialAmountProvided(val amount: Fiat) : Event + data object OnInitialAmountEntered : Event + data class OnVerificationNeeded(val phone: Boolean, val email: Boolean) : Event data object Exit : Event } @@ -291,18 +308,11 @@ class SwapViewModel @Inject constructor( eventFlow.filterIsInstance() .map { it.purpose } .flatMapLatest { purpose -> - val mint = when (purpose) { - is SwapPurpose.Buy -> purpose.mint - is SwapPurpose.FundWithWallet -> purpose.mint - is SwapPurpose.Sell -> purpose.mint - } + val mint = purpose.mint combine( tokenCoordinator.tokenBalances, - when (purpose) { - is SwapPurpose.FundWithWallet -> flowOf(exchange.rateForUsd()) - else -> exchange.observeEntryRate() - }, + exchange.observeEntryRate(), ) { tokens, rate -> var token = tokens.find { it.token.address == mint } if (token == null) { @@ -339,17 +349,13 @@ class SwapViewModel @Inject constructor( .flatMapLatest { purpose -> val tokenAddress = when (purpose) { is SwapPurpose.Buy -> Mint.usdf - is SwapPurpose.FundWithWallet -> Mint.usdf is SwapPurpose.Sell -> purpose.mint } combine( tokenCoordinator.tokens, tokenCoordinator.balanceForToken(tokenAddress), - when (purpose) { - is SwapPurpose.FundWithWallet -> flowOf(exchange.rateForUsd()) - else -> exchange.observeEntryRate() - }, + exchange.observeEntryRate(), ) { tokens, balance, rate -> val token = tokens.find { it.address == tokenAddress } ?: return@combine null TokenWithLocalizedBalance( @@ -368,10 +374,7 @@ class SwapViewModel @Inject constructor( combine( tokenCoordinator.observeReservesBalance(), - when (stateFlow.value.purpose) { - is SwapPurpose.FundWithWallet -> flowOf(exchange.rateForUsd()) - else -> exchange.observeEntryRate() - }, + exchange.observeEntryRate(), ) { balance, rate -> LocalFiat( usdf = balance, @@ -385,9 +388,10 @@ class SwapViewModel @Inject constructor( exchange.observeEntryRate() .onEach { - // reset when entry rate changes numberInputHelper.reset() - dispatchEvent(Event.OnAmountChanged(AmountAnimatedInputUiModel())) + if (stateFlow.value.pendingInitialAmount == null) { + dispatchEvent(Event.OnAmountChanged(AmountAnimatedInputUiModel())) + } }.launchIn(viewModelScope) transactionController.limits @@ -399,6 +403,10 @@ class SwapViewModel @Inject constructor( .map { it.currency } .onEach { numberInputHelper.fractionUnits = it.fractionUnits + val pending = stateFlow.value.pendingInitialAmount + if (pending != null) { + enterAmount(pending) + } }.launchIn(viewModelScope) eventFlow @@ -463,8 +471,7 @@ class SwapViewModel @Inject constructor( .map { stateFlow.value.amountEntryState.amountAnimatedModel } .filterNot { val purpose = stateFlow.value.purpose - // Don't check balance if funds are coming from external - if (purpose !is SwapPurpose.FundWithWallet) { + if (purpose is SwapPurpose.BalanceDecrease) { checkBalanceLimit() } else { false @@ -478,50 +485,49 @@ class SwapViewModel @Inject constructor( when (purpose) { is SwapPurpose.Buy -> { val rate = exchange.entryRate - // buy with reserves - val amountFiat = verifiedFiatCalculator.compute( - amount = Fiat(data.amountData.amount, rate.currency), - token = Token.usdf, - balance = stateFlow.value.reservesBalance.convertingToUsdIfNeeded(rate), - rate = rate - ).getOrElse { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_staleRates), - message = resources.getString(R.string.error_description_staleRates), - ) - return@onEach - } - val netAmount = amountFiat.localFiat.nativeAmount - - dispatchEvent(Event.UpdateBuyState(loading = true)) - dispatchEvent(Event.OnAmountAccepted(amountFiat, netTransferAmount = netAmount)) - dispatchEvent(Event.ProceedWithPurchase(amountFiat)) - } - - is SwapPurpose.FundWithWallet -> { - val rate = exchange.rateForUsd() - // funding through external wallet — no balance cap, - // funds come from the external wallet not reserves - val amountFiat = verifiedFiatCalculator.compute( - amount = Fiat(data.amountData.amount, rate.currency), - token = Token.usdf, - rate = rate, - ).getOrElse { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_staleRates), - message = resources.getString(R.string.error_description_staleRates), + val conversionRate = exchange.rateToUsd( + stateFlow.value.amountEntryState.currencyModel.code ?: CurrencyCode.USD + ) ?: Rate.ignore + val enteredInUsdf = Fiat( + data.amountData.amount, + stateFlow.value.amountEntryState.currencyModel.code ?: CurrencyCode.USD + ).convertingTo(conversionRate) + val reservesBalance = stateFlow.value.reservesBalance + + if (enteredInUsdf <= reservesBalance.rounded()) { + // Sufficient reserves — buy directly + val amountFiat = verifiedFiatCalculator.compute( + amount = Fiat(data.amountData.amount, rate.currency), + token = Token.usdf, + balance = reservesBalance.convertingToUsdIfNeeded(rate), + rate = rate + ).getOrElse { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_staleRates), + message = resources.getString(R.string.error_description_staleRates), + ) + return@onEach + } + val netAmount = amountFiat.localFiat.nativeAmount + + dispatchEvent(Event.UpdateBuyState(loading = true)) + dispatchEvent(Event.OnAmountAccepted(amountFiat, netTransferAmount = netAmount)) + dispatchEvent(Event.ProceedWithPurchase(amountFiat)) + } else { + // Insufficient reserves — check available purchase methods + val mint = purpose.mint + val metadata = PurchaseMethodMetadata( + mint = mint, + purchaseAmount = Fiat(data.amountData.amount, rate.currency), ) - return@onEach + val methods = purchaseMethodController.state.value.availableMethods + if (methods.size == 1) { + // Single method — skip sheet, handle directly + purchaseMethodController.select(methods.first(), metadata) + } else { + purchaseMethodController.present(metadata) + } } - - dispatchEvent(Event.OnAmountAccepted(amountFiat, netTransferAmount = amountFiat.localFiat.nativeAmount)) - dispatchEvent(Event.UpdateBuyState(loading = true)) - dispatchEvent( - Event.CreateAndSendTransactionToWallet( - token = stateFlow.value.tokenWithBalance!!.token, - amount = amountFiat - ) - ) } is SwapPurpose.Sell -> { @@ -547,6 +553,32 @@ class SwapViewModel @Inject constructor( } }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + val data = stateFlow.value.amountEntryState.amountAnimatedModel + val rate = exchange.rateForUsd() + val amountFiat = verifiedFiatCalculator.compute( + amount = Fiat(data.amountData.amount, rate.currency), + token = Token.usdf, + rate = rate, + ).getOrElse { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_staleRates), + message = resources.getString(R.string.error_description_staleRates), + ) + return@onEach + } + dispatchEvent(Event.OnAmountAccepted(amountFiat, netTransferAmount = amountFiat.localFiat.nativeAmount)) + dispatchEvent(Event.UpdateBuyState(loading = true)) + dispatchEvent( + Event.CreateAndSendTransactionToWallet( + token = stateFlow.value.tokenWithBalance!!.token, + amount = amountFiat + ) + ) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .map { stateFlow.value.amountEntryState.selectedAmount } @@ -686,13 +718,98 @@ class SwapViewModel @Inject constructor( dispatchEvent(Event.UpdateProcessingState(loading = false, success = false, error = true)) } ).launchIn(viewModelScope) + + // Reset buy loading state when Coinbase payment is canceled or fails. + // CoinbaseOnRampHandler handles error display; we just clear loading. + coinbaseOnRampController.state + .onEach { s -> + when (s) { + is CoinbaseOnRampState.Failed, + CoinbaseOnRampState.Idle -> dispatchEvent(Event.UpdateBuyState()) + is CoinbaseOnRampState.Completed, + is CoinbaseOnRampState.Paying -> Unit + } + }.launchIn(viewModelScope) + + purchaseMethodController.selections + .onEach { (method, metadata) -> + when (method) { + PurchaseMethod.CoinbaseOnRamp -> { + analytics.buttonTapped(Button.TokenBuyWithReserves) + val rate = exchange.entryRate + val amount = metadata.purchaseAmount ?: return@onEach + + if (amount < minimumCoinbasePurchaseAmount) { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_onrampAmountTooLow), + message = resources.getString(R.string.error_description_onrampAmountTooLow), + ) + return@onEach + } + + val amountFiat = verifiedFiatCalculator.compute( + amount = amount, + token = Token.usdf, + rate = rate, + ).getOrElse { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_staleRates), + message = resources.getString(R.string.error_description_staleRates), + ) + return@onEach + } + + dispatchEvent(Event.UpdateBuyState(loading = true)) + + val token = stateFlow.value.tokenWithBalance?.token ?: return@onEach + coinbaseOnRampController.placeOrderAndStartPayment( + amount = amountFiat.localFiat.underlyingTokenAmount, + token = token, + verifiedFiat = amountFiat, + ).onFailure { error -> + dispatchEvent(Event.UpdateBuyState()) + when (error) { + is OnRampAuthError.VerificationRequired -> + dispatchEvent(Event.OnVerificationNeeded(error.phone, error.email)) + is OnRampPaymentError.GooglePayNotSupported -> + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_onrampGooglePayNotSupported), + message = resources.getString(R.string.error_description_onrampGooglePayNotSupported), + ) + is OnRampPaymentError.GooglePayNoPaymentMethod -> + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_onrampGooglePayNotReady), + message = resources.getString(R.string.error_description_onrampGooglePayNotReady), + ) + else -> + BottomBarManager.showError( + title = resources.getString(R.string.error_title_buySellFailed), + message = resources.getString(R.string.error_description_buySellFailed), + ) + } + } + } + is PurchaseMethod.CashReserves -> { + dispatchEvent(Event.OnAmountConfirmed) + } + PurchaseMethod.PhantomWallet -> { + analytics.buttonTapped(Button.TokenBuyWithPhantom) + dispatchEvent(Event.PhantomSelected) + } + + PurchaseMethod.OtherWallet -> { + // TODO: + } + } + }.launchIn(viewModelScope) } private fun trackTransaction(token: Token, error: Throwable? = null) { - val purpose = stateFlow.value.purpose - val method = when (purpose) { - is SwapPurpose.Buy -> Analytics.SwapMethod.Buy.Reserves - is SwapPurpose.FundWithWallet -> Analytics.SwapMethod.Buy.Phantom + val method = when (val purpose = stateFlow.value.purpose) { + is SwapPurpose.Buy -> when (purpose.fundingSource) { + FundingSource.Phantom -> Analytics.SwapMethod.Buy.Phantom + else -> Analytics.SwapMethod.Buy.Reserves + } else -> Analytics.SwapMethod.Sell } @@ -720,6 +837,50 @@ class SwapViewModel @Inject constructor( exchange.resetEntryToBalance() } + private fun enterAmount(amount: Fiat) { + numberInputHelper.maxLength = 10 + val scale = pow10(numberInputHelper.fractionUnits) + val smallest = (amount.decimalValue * scale).roundToInt() + val whole = smallest / scale + val frac = smallest % scale + + if (whole == 0L) { + numberInputHelper.onNumber(0) + } else { + var div = 1L + while (div * 10 <= whole) div *= 10 + var n = whole + while (div > 0) { + numberInputHelper.onNumber((n / div).toInt()) + n %= div + div /= 10 + } + } + + if (frac > 0) { + numberInputHelper.onDot() + var div = scale / 10 + var n = frac + while (div > 0) { + numberInputHelper.onNumber((n / div).toInt()) + n %= div + if (n == 0L) break + div /= 10 + } + } + + dispatchEvent(Event.OnEnteredNumberChanged()) + dispatchEvent(Event.OnInitialAmountEntered) + } + + private fun pow10(n: Int): Long { + var result = 1L + repeat(n) { result *= 10 } + return result + } + + private val minimumCoinbasePurchaseAmount = 5.toFiat() + internal companion object { val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { @@ -771,6 +932,16 @@ class SwapViewModel @Inject constructor( ) } + Event.PhantomSelected -> { state -> + val purpose = state.purpose + if (purpose is SwapPurpose.Buy) { + state.copy(purpose = purpose.copy(fundingSource = FundingSource.Phantom)) + } else { + state + } + } + + Event.ConfirmPhantomTransaction, Event.OnAmountConfirmed, Event.OnBackspace, Event.OnDecimalPressed, @@ -818,6 +989,13 @@ class SwapViewModel @Inject constructor( is Event.OnSellConfirmed -> { state -> state } is Event.OnSellSubmitted -> { state -> state } Event.ShowSellReceipt -> { state -> state } + is Event.OnInitialAmountProvided -> { state -> + state.copy(minimumBuyAmount = event.amount, pendingInitialAmount = event.amount) + } + Event.OnInitialAmountEntered -> { state -> + state.copy(pendingInitialAmount = null) + } + is Event.OnVerificationNeeded -> { state -> state } Event.Exit -> { state -> state } } } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 49dddd44a..8bf1a016b 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -1,7 +1,6 @@ package com.flipcash.app.tokens.ui import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.core.data.Loadable @@ -9,9 +8,8 @@ import com.flipcash.app.core.data.isLoaded import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.payments.PurchaseMethod import com.flipcash.app.payments.PurchaseMethodController -import com.flipcash.app.payments.PurchaseMethodMetadata + import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable import com.flipcash.app.tokens.TokenCoordinator @@ -50,8 +48,6 @@ class TokenInfoViewModel @Inject constructor( private val exchange: Exchange, private val shareController: ShareSheetController, private val resources: ResourceHelper, - private val analytics: FlipcashAnalyticsService, - private val purchaseMethodController: PurchaseMethodController, features: FeatureFlagController, dispatchers: DispatcherProvider, ) : BaseViewModel2( @@ -98,9 +94,8 @@ class TokenInfoViewModel @Inject constructor( data class OnAppreciationUpdated(val amount: LocalFiat?) : Event data class ExpandDescription(val expand: Boolean) : Event data object Share : Event - data class OpenPurchaseMethods(val shortFall: Fiat? = null) : Event + data class OnBuy(val shortFall: Fiat? = null) : Event data class OpenScreen(val screen: AppRoute) : Event - data object ConnectPhantomWallet : Event data object Exit : Event } @@ -178,7 +173,7 @@ class TokenInfoViewModel @Inject constructor( .map { it.shortFall } .filterNotNull() .onEach { - dispatchEvent(Event.OpenPurchaseMethods(it)) + dispatchEvent(Event.OnBuy(it)) }.launchIn(viewModelScope) eventFlow @@ -263,41 +258,16 @@ class TokenInfoViewModel @Inject constructor( .launchIn(viewModelScope) eventFlow - .filterIsInstance() + .filterIsInstance() .mapNotNull { val mint = stateFlow.value.mint ?: return@mapNotNull null - PurchaseMethodMetadata(mint, purchaseAmount = it.shortFall) - } - .onEach { metadata -> - purchaseMethodController.present(metadata) + SwapPurpose.Buy(mint) to it.shortFall } - .launchIn(viewModelScope) - - purchaseMethodController.selections - .onEach { (method, metadata) -> - when (method) { - PurchaseMethod.CoinbaseOnRamp -> { - val mint = metadata.mint ?: return@onEach - analytics.buttonTapped(Button.TokenBuyWithCoinbase) - dispatchEvent(Event.OpenScreen(AppRoute.Token.OnRamp(mint))) - } - is PurchaseMethod.CashReserves -> { - val mint = metadata.mint ?: return@onEach - analytics.buttonTapped(Button.TokenBuyWithReserves) - dispatchEvent( - Event.OpenScreen( - AppRoute.Token.Swap( - purpose = SwapPurpose.Buy(mint), - shortfall = metadata.purchaseAmount - ) - ) - ) - } - PurchaseMethod.PhantomWallet -> { - analytics.buttonTapped(Button.TokenBuyWithPhantom) - dispatchEvent(Event.ConnectPhantomWallet) - } - } + .onEach { (purpose, shortfall) -> + dispatchEvent(Event.OpenScreen(AppRoute.Token.Swap( + purpose = purpose, + shortfall = shortfall, + ))) } .launchIn(viewModelScope) @@ -330,8 +300,7 @@ class TokenInfoViewModel @Inject constructor( is Event.OnMarketCapPeriodSelected -> { state -> state.copy(selectedPeriod = event.period) } is Event.OpenScreen -> { state -> state } - is Event.ConnectPhantomWallet -> { state -> state } - is Event.OpenPurchaseMethods -> { state -> state } + is Event.OnBuy -> { state -> state } is Event.LoadHistoricalDataForPeriod -> { state -> state } is Event.Share -> { state -> state } is Event.Exit -> { state -> state } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt index 694334420..cca327e4c 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt @@ -3,6 +3,8 @@ package com.flipcash.app.tokens.ui import com.flipcash.app.activityfeed.ActivityFeedCoordinator import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.tokens.SwapPurpose +import com.flipcash.app.onramp.CoinbaseOnRampController +import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.user.UserManager import com.flipcash.shared.tokens.R @@ -58,6 +60,8 @@ class SwapViewModelErrorTest { private val tokenCoordinator = mockk(relaxed = true) private val feedCoordinator = mockk(relaxed = true) private val analytics = mockk(relaxed = true) + private val purchaseMethodController = mockk(relaxed = true) + private val coinbaseOnRampController = mockk(relaxed = true) private val accountCluster = mockk(relaxed = true) @@ -96,6 +100,8 @@ class SwapViewModelErrorTest { tokenCoordinator = tokenCoordinator, feedCoordinator = feedCoordinator, analytics = analytics, + purchaseMethodController = purchaseMethodController, + coinbaseOnRampController = coinbaseOnRampController, dispatchers = dispatchers, ) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0c668c0a6..78b196dc6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -94,7 +94,6 @@ include( ":apps:flipcash:features:backupkey", ":apps:flipcash:features:shareapp", ":apps:flipcash:features:withdrawal", - ":apps:flipcash:features:onramp", ":apps:flipcash:features:contact-verification", ":apps:flipcash:features:tokens", ":apps:flipcash:features:transactions", diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberInputHelper.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberInputHelper.kt index 2d85312c2..2a9700d6a 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberInputHelper.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberInputHelper.kt @@ -131,4 +131,4 @@ private fun String.toLocaleAwareDoubleOrNull(): Double? { // or ends with it, which parse() would fail on. val sanitizedText = if (endsWith(separator)) this + "0" else this return runCatching { NumberFormat.getInstance().parse(sanitizedText)?.toDouble() }.getOrNull() -} \ No newline at end of file +}