diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index ba284a909..fed44faac 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -740,6 +740,7 @@ Cash Sent Successfully %1$s is on its way to %2$s + your selected recipient Something Went Wrong We were unable to send your cash. Please try again 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 68ff1e143..60986dc11 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,15 +3,11 @@ 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 @@ -35,7 +31,6 @@ 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 @@ -91,34 +86,28 @@ private fun SendAmountEntryScreen() { val sharedState by sharedVm.stateFlow.collectAsStateWithLifecycle() val resources = LocalResources.current - var contact by remember { - mutableStateOf(null) - } - - var resolvedAuthority by remember { - mutableStateOf(null) - } - - LaunchedEffect(Unit) { - sharedVm.eventFlow - .filterIsInstance() - .onEach { - contact = it.contact - resolvedAuthority = it.authority - } - .launchIn(this) + LaunchedEffect(sharedState.resolveState) { + if (sharedState.resolveState is SendFlowViewModel.ResolveState.Failed) { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_contactNotOnFlipcash), + message = resources.getString(R.string.error_description_contactNotOnFlipcash), + onDismiss = { flowNavigator.back() } + ) + } } LaunchedEffect(sharedVm) { sharedVm.eventFlow .filterIsInstance() .onEach { event -> + val displayName = sharedState.resolveState.contact?.displayName + ?: resources.getString(R.string.subtitle_yourSelectedRecipient) 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" + displayName, ), onDismiss = { flowNavigator.back() } ) @@ -143,21 +132,20 @@ private fun SendAmountEntryScreen() { 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, + when (val resolve = sharedState.resolveState) { + is SendFlowViewModel.ResolveState.Resolved -> { + sharedVm.dispatchEvent( + SendFlowViewModel.Event.OnSendRequested( + amount = result.amount, + token = result.token, + destinationOwner = resolve.authority, + ) ) - ) + } + // Resolve still in flight — slide resets, user can retry + is SendFlowViewModel.ResolveState.Pending -> Unit + // Failed case handled by LaunchedEffect above + else -> Unit } } } 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 bfc8d6464..1fd1d71df 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 @@ -61,6 +61,14 @@ internal class SendFlowViewModel @Inject constructor( updateStateForEvent = updateStateForEvent, ) { + sealed interface ResolveState { + val contact: DeviceContact? get() = null + data object Idle : ResolveState + data class Pending(override val contact: DeviceContact) : ResolveState + data class Resolved(override val contact: DeviceContact, val authority: PublicKey) : ResolveState + data class Failed(override val contact: DeviceContact) : ResolveState + } + data class State @OptIn(ExperimentalMaterial3Api::class) constructor( val steps: List = listOf(SendStep.ContactList), val currentStep: SendStep? = null, @@ -69,6 +77,7 @@ internal class SendFlowViewModel @Inject constructor( val contactSyncState: LoadingSuccessState = LoadingSuccessState(), val listItems: List = emptyList(), val sendProgress: LoadingSuccessState = LoadingSuccessState(), + val resolveState: ResolveState = ResolveState.Idle, ) sealed interface Event { @@ -108,7 +117,6 @@ internal class SendFlowViewModel @Inject constructor( val success: Boolean = false, ) : Event data class SendComplete(val amount: Fiat) : Event - data object ContactNotResolved : Event } init { @@ -223,11 +231,16 @@ internal class SendFlowViewModel @Inject constructor( .onEach { event -> contactCoordinator.removeContact(event.e164) } .launchIn(viewModelScope) - contactCoordinator.state - .filter { it.hasDiscoveredFlipcashContacts && it.flipcashE164s.isNotEmpty() } - .filter { stateFlow.value.currentStep is SendStep.ContactList } + combine( + contactCoordinator.state, + stateFlow.map { it.currentStep }, + ) { contactState, currentStep -> + contactState to currentStep + } + .filter { (cs, _) -> cs.hasDiscoveredFlipcashContacts && cs.flipcashE164s.isNotEmpty() } + .filter { (_, step) -> step is SendStep.ContactList } .take(1) - .onEach { contactState -> + .onEach { (contactState, _) -> val count = contactState.flipcashE164s.size contactCoordinator.consumeContactsDiscovery() BottomBarManager.showInfo( @@ -283,15 +296,12 @@ internal class SendFlowViewModel @Inject constructor( }, 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( @@ -362,10 +372,21 @@ internal class SendFlowViewModel @Inject constructor( is Event.OnItemsPopulated -> { state -> state.copy(listItems = event.items) } is Event.OnContactClicked -> { state -> state } is Event.SendInvite -> { state -> state } - is Event.SendCashToContact -> { state -> state } + is Event.SendCashToContact -> { state -> + state.copy(resolveState = ResolveState.Pending(event.contact)) + } is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } - is Event.ResolveCompleted -> { state -> state } - is Event.ResolveFailed -> { state -> state } + is Event.ResolveCompleted -> { state -> + state.copy(resolveState = ResolveState.Resolved(event.contact, event.authority)) + } + is Event.ResolveFailed -> { state -> + val contact = state.resolveState.contact + if (contact != null) { + state.copy(resolveState = ResolveState.Failed(contact)) + } else { + state + } + } is Event.OnSendRequested -> { state -> state } is Event.SendStateUpdated -> { state -> state.copy( @@ -376,7 +397,6 @@ internal class SendFlowViewModel @Inject constructor( ) } is Event.SendComplete -> { state -> state } - Event.ContactNotResolved -> { state -> state } } } }