From 1b6252e7373d37b291543cc12cfc941d3b4ad760 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 2 Jun 2026 09:40:51 -0400 Subject: [PATCH] feat(direct-send): wire contact selection -> amount entry -> send flow SendFlowViewModel gains directTransfer orchestration to resolve contact, send, and show result. SendFlowScreen registers the AmountEntry step. ContactListScreen listens for NavigateToAmountEntry events. Adds SendStep.AmountEntry, SendResult.Sent, and new string resources. Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/core/send/SendStep.kt | 14 +- .../core/src/main/res/values/strings.xml | 13 ++ .../flipcash/app/directsend/SendFlowScreen.kt | 130 ++++++++++++++-- .../directsend/internal/SendFlowViewModel.kt | 140 +++++++++++++++++- .../internal/screens/ContactListScreen.kt | 16 ++ 5 files changed, 299 insertions(+), 14 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/send/SendStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/send/SendStep.kt index 282e2e24e..6cb7d84e3 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/send/SendStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/send/SendStep.kt @@ -2,6 +2,7 @@ package com.flipcash.app.core.send import android.os.Parcelable import com.getcode.navigation.flow.FlowStep +import com.getcode.opencode.model.financial.Fiat import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -22,7 +23,18 @@ sealed interface SendStep : FlowStep, Parcelable { @Parcelize @Serializable data object ContactList : SendStep + + @Parcelize + @Serializable + data class AmountEntry( + val e164: String, + val displayName: String, + ) : SendStep } @Serializable -sealed interface SendResult : Parcelable +sealed interface SendResult : Parcelable { + @Parcelize + @Serializable + data class Sent(val amount: Fiat) : SendResult +} diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index cddd0576f..ba284a909 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -52,6 +52,9 @@ Enter up to %1$s You can only give up to %1$s + Enter up to %1$s + You can only send up to %1$s + Transaction Limit Reached Flipcash is designed for small, every day transactions. Send limits reset daily @@ -731,4 +734,14 @@ %1$s Contacts Already On Flipcash Send them money, or invite other contacts to sign up for Flipcash + + Something Went Wrong + This contact isn\'t on Flipcash. Pick someone else to send cash + + Cash Sent Successfully + %1$s is on its way to %2$s + + Something Went Wrong + We were unable to send your cash. Please try again + Swipe to Send \ No newline at end of file diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt index 9a7789eef..68ff1e143 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt @@ -3,23 +3,43 @@ package com.flipcash.app.directsend import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider +import com.flipcash.app.contacts.device.DeviceContact +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.send.SendResult import com.flipcash.app.core.send.SendStep +import com.flipcash.app.core.tokens.TokenPurpose +import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.app.core.ui.TokenSelectionPill import com.flipcash.app.directsend.internal.SendFlowViewModel +import com.flipcash.app.directsend.internal.screens.AmountEntryResult +import com.flipcash.app.directsend.internal.screens.AmountEntryScreen import com.flipcash.app.directsend.internal.screens.ContactListScreen import com.flipcash.app.directsend.internal.screens.ContactsPermissionGateScreen import com.flipcash.app.directsend.internal.screens.PhoneGateLandingScreen +import com.flipcash.features.directsend.R +import com.getcode.manager.BottomBarManager import com.getcode.navigation.annotatedEntry import com.getcode.navigation.flow.FlowExitReason import com.getcode.navigation.flow.FlowHost -import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.flow.LocalOuterCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.PublicKey +import com.getcode.util.resources.LocalResources +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach @Composable fun SendFlowScreen(resultStateRegistry: NavResultStateRegistry) { @@ -42,19 +62,107 @@ fun SendFlowScreen(resultStateRegistry: NavResultStateRegistry) { ) } -private fun sendEntryProvider(): (NavKey) -> NavEntry = entryProvider { - annotatedEntry { - SyncStep(it) - PhoneGateLandingScreen() +@Composable +private fun sendEntryProvider(): (NavKey) -> NavEntry { + return entryProvider { + annotatedEntry { + SyncStep(it) + PhoneGateLandingScreen() + } + annotatedEntry { + SyncStep(it) + ContactsPermissionGateScreen() + } + annotatedEntry { + SyncStep(it) + ContactListScreen() + } + annotatedEntry { step -> + SyncStep(step) + SendAmountEntryScreen() + } } - annotatedEntry { - SyncStep(it) - ContactsPermissionGateScreen() +} + +@Composable +private fun SendAmountEntryScreen() { + val flowNavigator = rememberFlowNavigator() + val sharedVm = flowSharedViewModel() + val sharedState by sharedVm.stateFlow.collectAsStateWithLifecycle() + val resources = LocalResources.current + + var contact by remember { + mutableStateOf(null) } - annotatedEntry { - SyncStep(it) - ContactListScreen() + + var resolvedAuthority by remember { + mutableStateOf(null) } + + LaunchedEffect(Unit) { + sharedVm.eventFlow + .filterIsInstance() + .onEach { + contact = it.contact + resolvedAuthority = it.authority + } + .launchIn(this) + } + + LaunchedEffect(sharedVm) { + sharedVm.eventFlow + .filterIsInstance() + .onEach { event -> + BottomBarManager.showInfo( + title = resources.getString(R.string.prompt_title_fundsSentToContact), + message = resources.getString( + R.string.prompt_description_fundsSentToContact, + event.amount.formatted(rule = Fiat.FormattingRule.Truncated), + contact?.displayName ?: "your selected recipient" + ), + onDismiss = { flowNavigator.back() } + ) + }.launchIn(this) + } + + AmountEntryScreen( + title = { token -> + TokenSelectionPill(token) { + flowNavigator.navigate( + AppRoute.Sheets.TokenSelection(TokenPurpose.Select) + ) + } + }, + // region lives outside the flow + // flow replaces LocalCodeNavigator for the flow as the "inner" navigator + navigator = LocalOuterCodeNavigator.current, + canChangeCurrency = true, + confirmationStyle = ConfirmationStyle.Slide, + confirmationState = sharedState.sendProgress, + onResult = { result -> + when (result) { + AmountEntryResult.Cancelled -> flowNavigator.back() + is AmountEntryResult.Confirmed -> { + val destination = resolvedAuthority + if (destination == null) { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_contactNotOnFlipcash), + message = resources.getString(R.string.error_description_contactNotOnFlipcash), + onDismiss = { flowNavigator.back() } + ) + } else { + sharedVm.dispatchEvent( + SendFlowViewModel.Event.OnSendRequested( + amount = result.amount, + token = result.token, + destinationOwner = destination, + ) + ) + } + } + } + }, + ) } @Composable diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index aee73236c..77d339e8c 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -2,13 +2,14 @@ package com.flipcash.app.directsend.internal import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SearchBarState import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.viewModelScope import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.contacts.ContactCoordinator.ContactState import com.flipcash.app.contacts.device.DeviceContact import com.flipcash.app.contacts.device.PickedContactData +import com.flipcash.app.core.extensions.flatMapResult +import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.send.SendStep import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController @@ -16,18 +17,31 @@ import com.flipcash.app.permissions.PickedContact import com.flipcash.features.directsend.R import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.toFiat +import com.getcode.opencode.model.transactions.TransactionMetadata +import com.getcode.solana.keys.PublicKey import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject @@ -35,10 +49,13 @@ import kotlin.time.Duration.Companion.seconds @HiltViewModel internal class SendFlowViewModel @Inject constructor( - userManager: UserManager, + private val userManager: UserManager, featureFlags: FeatureFlagController, private val contactCoordinator: ContactCoordinator, private val resources: ResourceHelper, + private val exchange: Exchange, + private val verifiedFiatCalculator: VerifiedFiatCalculator, + private val transactionController: TransactionController, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -51,6 +68,7 @@ internal class SendFlowViewModel @Inject constructor( val isPickerMode: Boolean = false, val contactSyncState: LoadingSuccessState = LoadingSuccessState(), val listItems: List = emptyList(), + val sendProgress: LoadingSuccessState = LoadingSuccessState(), ) sealed interface Event { @@ -71,6 +89,26 @@ internal class SendFlowViewModel @Inject constructor( data class ContactRemoved(val e164: String) : Event data class SendInvite(val contact: DeviceContact) : Event data class SendCashToContact(val contact: DeviceContact) : Event + + data class NavigateToAmountEntry( + val e164: String, + val displayName: String, + ) : Event + + data class ResolveCompleted(val contact: DeviceContact, val authority: PublicKey) : Event + data class ResolveFailed(val e164: String) : Event + + data class OnSendRequested( + val amount: Fiat, + val token: Token, + val destinationOwner: PublicKey, + ) : Event + data class SendStateUpdated( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + data class SendComplete(val amount: Fiat) : Event + data object ContactNotResolved : Event } init { @@ -159,6 +197,27 @@ internal class SendFlowViewModel @Inject constructor( } }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .map { it.contact } + .flatMapLatest { contact -> + flow { + dispatchEvent(Event.NavigateToAmountEntry( + e164 = contact.e164, + displayName = contact.displayName, + )) + + contactCoordinator.resolve(contact.e164) + .onSuccess { authority -> + dispatchEvent(Event.ResolveCompleted(contact, authority)) + } + .onFailure { + dispatchEvent(Event.ResolveFailed(contact.e164)) + } + emit(Unit) + } + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { event -> contactCoordinator.removeContact(event.e164) } @@ -185,6 +244,67 @@ internal class SendFlowViewModel @Inject constructor( ) } .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { (amount, token, destination) -> + viewModelScope.launch { + val owner = userManager.accountCluster ?: return@launch + val rate = exchange.preferredRate + + dispatchEvent(Event.SendStateUpdated(loading = true)) + + val source = owner.withTimelockForToken(token) + + val verifiedFiat = verifiedFiatCalculator.compute( + amount = amount, + token = token, + rate = rate, + ).getOrElse { error -> + dispatchEvent(Event.SendStateUpdated()) + val (title, message) = when (error) { + is ComputeVerifiedFiatError.AmountBelowMinimum -> { + R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall + } + else -> { + R.string.error_title_staleRates to R.string.error_description_staleRates + } + } + BottomBarManager.showAlert( + title = resources.getString(title), + message = resources.getString(message), + ) + return@launch + } + + transactionController.directTransfer( + amount = verifiedFiat, + token = token, + source = source, + destinationOwner = destination, + ).fold( + onSuccess = { + Result.success(verifiedFiat) + }, + onFailure = { Result.failure(it) } + ).onSuccess { amount -> + timber.log.Timber.d("directTransfer success, dispatching checkmark") + dispatchEvent(Event.SendStateUpdated(success = true)) + delay(400) + timber.log.Timber.d("dispatching SendComplete") + dispatchEvent( + Dispatchers.Main, + Event.SendComplete(amount.localFiat.nativeAmount) + ) + timber.log.Timber.d("SendComplete dispatched") + }.onFailure { + dispatchEvent(Event.SendStateUpdated()) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_cashFailedToSend), + message = resources.getString(R.string.error_description_cashFailedToSend), + ) + } + } + }.launchIn(viewModelScope) } private fun generateListItems( @@ -247,6 +367,22 @@ internal class SendFlowViewModel @Inject constructor( is Event.OnContactClicked -> { state -> state } is Event.SendInvite -> { state -> state } is Event.SendCashToContact -> { state -> state } + is Event.NavigateToAmountEntry -> { state -> state } + is Event.ResolveCompleted -> { state -> state } + is Event.ResolveFailed -> { state -> state } + is Event.OnSendRequested -> { state -> state } + is Event.SendStateUpdated -> { state -> + state.copy( + sendProgress = LoadingSuccessState( + event.loading, + event.success, + ) + ) + } + is Event.SendComplete -> { state -> + state.copy(sendProgress = LoadingSuccessState()) + } + Event.ContactNotResolved -> { state -> state } } } } diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt index 898de2f44..818796c60 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -84,7 +85,9 @@ import com.getcode.ui.components.CircularIconButton import com.getcode.ui.components.SearchInput import com.getcode.ui.core.rememberedClickable import com.getcode.ui.core.verticalScrollStateGradient +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.theme.CodeScaffold +import com.getcode.view.LoadingSuccessState import kotlin.math.abs import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch @@ -106,6 +109,19 @@ internal fun ContactListScreen() { } } + LaunchedEffect(Unit) { + viewModel.eventFlow + .filterIsInstance() + .collect { event -> + flowNavigator.navigateTo( + SendStep.AmountEntry( + e164 = event.e164, + displayName = event.displayName, + ) + ) + } + } + val accessHandle = rememberContactAccessHandle( isPickerMode = state.isPickerMode, ) { result ->