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 ->