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