From b49afc99a7c1ff6221c42835e1dbd724c8e738ad Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 29 May 2026 15:44:46 -0400 Subject: [PATCH] fix(verification): await linkForPayment before advancing phone flow Fix race where linkForPayment was fire-and-forget via eventFlow but the flow exited before the network call completed. Introduce OnPhoneVerificationComplete event that gates navigation until linkForPayment finishes. Signed-off-by: Brandon McAnsh --- .../phone/PhoneVerificationViewModel.kt | 41 +++++++++++++---- .../verification/phone/PhoneCodeScreen.kt | 12 ++++- .../flipcash/app/myaccount/MyAccountScreen.kt | 3 +- .../internal/MyAccountScreenViewModel.kt | 45 +++++++++++++------ 4 files changed, 78 insertions(+), 23 deletions(-) diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt index 215b7a498..3b7aa8018 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt @@ -17,12 +17,13 @@ import com.flipcash.services.user.UserManager import com.flipcash.services.models.PhoneVerificationError import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.TraceType +import com.getcode.utils.trace import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn @@ -95,6 +96,7 @@ internal class PhoneVerificationViewModel @Inject constructor( data object OnVerifyCodeClicked : Event data object OnCodeVerified : Event data object LinkForPayment : Event + data object OnPhoneVerificationComplete : Event data object OnMaxAttemptsReached : Event } @@ -166,11 +168,15 @@ internal class PhoneVerificationViewModel @Inject constructor( val cleanedNumber = phoneUtils.cleanNumber(number, locale) ContactMethod.Phone(cleanedNumber) } - .onEach { dispatchEvent(Event.OnVerifyingCodeChanged(loading = true)) } + .onEach { + trace(message = "Verifying code", type = TraceType.Process) + dispatchEvent(Event.OnVerifyingCodeChanged(loading = true)) + } .map { method -> verificationController.checkVerificationCode(method, stateFlow.value.codeTextFieldState.text.toString()) }.onResult( onSuccess = { + trace(message = "Code verified successfully", type = TraceType.Process) stopTimer() dispatchEvent(Event.OnVerifyingCodeChanged(success = true)) viewModelScope.launch { @@ -184,6 +190,7 @@ internal class PhoneVerificationViewModel @Inject constructor( } }, onError = { + trace(message = "Code verification failed: $it", type = TraceType.Error) dispatchEvent(Event.OnVerifyingCodeChanged()) val (title, message) = when (it) { is PhoneVerificationError -> when (it) { @@ -206,18 +213,24 @@ internal class PhoneVerificationViewModel @Inject constructor( eventFlow .filterIsInstance() - .filter { - featureFlags.observe(FeatureFlag.PhoneNumberSend).value || - userManager.state.value.flags?.enablePhoneNumberSend == true - } .map { val number = stateFlow.value.numberTextFieldState.text.toString() val locale = stateFlow.value.selectedLocale val cleanedNumber = phoneUtils.cleanNumber(number, locale) + trace(message = "Calling linkForPayment", type = TraceType.Process) ContactMethod.Phone(cleanedNumber) } - .map { verificationController.linkForPayment(it) } - .launchIn(viewModelScope) + .map { method -> verificationController.linkForPayment(method) } + .onResult( + onSuccess = { + trace(message = "linkForPayment succeeded", type = TraceType.Process) + dispatchEvent(Event.OnPhoneVerificationComplete) + }, + onError = { + trace(message = "linkForPayment failed: $it", type = TraceType.Error) + dispatchEvent(Event.OnPhoneVerificationComplete) + } + ).launchIn(viewModelScope) } private suspend fun handleSendVerificationCode(method: ContactMethod) { @@ -268,6 +281,17 @@ internal class PhoneVerificationViewModel @Inject constructor( } } + suspend fun awaitLinkForPayment() { + val isEnabled = featureFlags.observe(FeatureFlag.PhoneNumberSend).value || + userManager.state.value.flags?.enablePhoneNumberSend == true + if (!isEnabled) return + + val number = stateFlow.value.numberTextFieldState.text.toString() + val locale = stateFlow.value.selectedLocale + val cleanedNumber = phoneUtils.cleanNumber(number, locale) + verificationController.linkForPayment(ContactMethod.Phone(cleanedNumber)) + } + private fun startTimer() { dispatchEvent( Event.OnTimerTick( @@ -319,6 +343,7 @@ internal class PhoneVerificationViewModel @Inject constructor( Event.OnVerifyCodeClicked -> { state -> state } Event.OnCodeVerified -> { state -> state } Event.LinkForPayment -> { state -> state } + Event.OnPhoneVerificationComplete -> { state -> state } is Event.OnPhoneNumberFormatted -> { state -> state.copy(formattedPhone = event.formatted) } Event.OnSendCodeClicked -> { state -> state.copy(attempts = state.attempts + 1) } Event.OnMaxAttemptsReached -> { state -> state } diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt index 8aa523933..889e0942c 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt @@ -56,13 +56,23 @@ fun PhoneCodeContent( .launchIn(this) } - LaunchedEffect(viewModel, includeEmail, linkForPayment) { + LaunchedEffect(viewModel, linkForPayment) { viewModel.eventFlow .filterIsInstance() .onEach { if (linkForPayment) { viewModel.dispatchEvent(PhoneVerificationViewModel.Event.LinkForPayment) + } else { + viewModel.dispatchEvent(PhoneVerificationViewModel.Event.OnPhoneVerificationComplete) } + } + .launchIn(this) + } + + LaunchedEffect(viewModel, includeEmail) { + viewModel.eventFlow + .filterIsInstance() + .onEach { if (includeEmail) { flowNavigator.navigateTo(VerificationStep.EmailEntry) } else { diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt index 5360ec77a..d1fa5f981 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt @@ -77,12 +77,13 @@ fun MyAccountScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() + .filterIsInstance() .onEach { val flow = AppRoute.Verification( origin = AppRoute.Menu.MyAccount, includePhone = true, includeEmail = false, + linkForPayment = it.linkForPayment ) navigator.push(flow) } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt index 9b68d1c6d..5b69179f6 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt @@ -4,6 +4,7 @@ import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.extensions.setText +import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem import com.flipcash.features.myaccount.R @@ -54,6 +55,7 @@ internal class MyAccountScreenViewModel @Inject constructor( val accountId: String? = null, val publicKey: String? = null, val pushToken: String? = null, + val linkForPayment: Boolean = false, val items: List> = FullMenuList ) @@ -61,7 +63,8 @@ internal class MyAccountScreenViewModel @Inject constructor( data class OnUserAssociated( val userId: String?, val publicKey: String?, - val pushToken: String? = null + val pushToken: String? = null, + val linkForPayment: Boolean = false, ) : Event data class OnBetaFeaturesUnlocked(val unlocked: Boolean) : Event @@ -71,6 +74,7 @@ internal class MyAccountScreenViewModel @Inject constructor( data object OnViewAccessKey : Event data object OnVerifyEmailClicked : Event data object OnVerifyPhoneClicked : Event + data class ConnectPhoneClicked(val linkForPayment: Boolean) : Event data object OnDeleteAccountClicked : Event data object OnAccountDeleted : Event data object CopyPublicKey : Event @@ -81,19 +85,26 @@ internal class MyAccountScreenViewModel @Inject constructor( } init { - userManager.state - .onEach { - val userId = it.accountId?.base64 - val publicKey = it.cluster?.authorityPublicKey?.base58() + combine( + userManager.state, + featureFlagController.observe(FeatureFlag.PhoneNumberSend), + ) { state, sendEnabled -> + val userId = state.accountId?.base64 + val publicKey = state.cluster?.authorityPublicKey?.base58() - dispatchEvent( - Event.OnUserAssociated( - userId = userId, - publicKey = publicKey, - pushToken = it.pushToken - ) + val linkForPayment = sendEnabled || + state.flags?.enablePhoneNumberSend == true + + dispatchEvent( + Event.OnUserAssociated( + userId = userId, + publicKey = publicKey, + pushToken = state.pushToken, + linkForPayment = linkForPayment, ) - }.launchIn(viewModelScope) + ) + + }.launchIn(viewModelScope) combine( featureFlagController.observeOverride(), @@ -183,6 +194,12 @@ internal class MyAccountScreenViewModel @Inject constructor( ) }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + dispatchEvent(Event.ConnectPhoneClicked(stateFlow.value.linkForPayment)) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { @@ -228,13 +245,15 @@ internal class MyAccountScreenViewModel @Inject constructor( state.copy( accountId = event.userId, publicKey = event.publicKey, - pushToken = event.pushToken + pushToken = event.pushToken, + linkForPayment = event.linkForPayment, ) } Event.OnLogOutClicked, Event.OnLoggedOutCompletely, Event.OnVerifyPhoneClicked, + is Event.ConnectPhoneClicked, Event.OnVerifyEmailClicked, Event.OnViewAccessKey, Event.CopyPublicKey,