Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restore Link Sign Up viewmodel and tests #9377

Merged
merged 12 commits into from
Oct 9, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,7 @@ internal class DefaultLinkAccountManager @Inject constructor(
}
}

/**
* Registers the user for a new Link account.
*/
private suspend fun signUp(
override suspend fun signUp(
email: String,
phone: String,
country: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.stripe.android.link.account
import com.stripe.android.link.LinkPaymentDetails
import com.stripe.android.link.model.AccountStatus
import com.stripe.android.link.model.LinkAccount
import com.stripe.android.link.ui.inline.SignUpConsentAction
import com.stripe.android.link.ui.inline.UserInput
import com.stripe.android.model.ConsumerSession
import com.stripe.android.model.ConsumerSessionLookup
Expand All @@ -29,6 +30,17 @@ internal interface LinkAccountManager {
startSession: Boolean = true,
): Result<LinkAccount?>

/**
* Registers the user for a new Link account.
*/
suspend fun signUp(
email: String,
phone: String,
country: String,
name: String?,
consentAction: SignUpConsentAction
): Result<LinkAccount>

/**
* Use the user input in memory to sign in to an existing account or sign up for a new Link
* account, starting verification if needed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ internal class DefaultLinkEventsReporter @Inject constructor(
fireEvent(LinkEvent.SignUpCheckboxChecked)
}

override fun onSignupFlowPresented() {
fireEvent(LinkEvent.SignUpFlowPresented)
}

override fun onSignupStarted(isInline: Boolean) {
durationProvider.start(DurationProvider.Key.LinkSignup)
fireEvent(LinkEvent.SignUpStart)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ internal sealed class LinkEvent : AnalyticsEvent {
override val eventName = "link.signup.checkbox_checked"
}

data object SignUpFlowPresented : LinkEvent() {
override val eventName = "link.signup.flow_presented"
}

object SignUpStart : LinkEvent() {
override val eventName = "link.signup.start"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ internal interface LinkEventsReporter {
fun onInvalidSessionState(state: SessionState)

fun onInlineSignupCheckboxChecked()
fun onSignupFlowPresented()
fun onSignupStarted(isInline: Boolean = false)
fun onSignupCompleted(isInline: Boolean = false)
fun onSignupFailure(isInline: Boolean = false, error: Throwable)
Expand Down
amk-stripe marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.stripe.android.link.ui.signup

import androidx.compose.runtime.Immutable
import com.stripe.android.core.strings.ResolvableString

@Immutable
internal data class SignUpScreenState(
val signUpEnabled: Boolean,
val signUpState: SignUpState = SignUpState.InputtingPrimaryField,
val errorMessage: ResolvableString? = null
)
amk-stripe marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package com.stripe.android.link.ui.signup

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.stripe.android.core.Logger
import com.stripe.android.core.model.CountryCode
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.link.LinkActivityContract
import com.stripe.android.link.LinkScreen
import com.stripe.android.link.account.LinkAccountManager
import com.stripe.android.link.analytics.LinkEventsReporter
import com.stripe.android.link.model.LinkAccount
import com.stripe.android.link.ui.ErrorMessage
import com.stripe.android.link.ui.getErrorMessage
import com.stripe.android.link.ui.inline.SignUpConsentAction
import com.stripe.android.model.PaymentIntent
import com.stripe.android.model.SetupIntent
import com.stripe.android.uicore.elements.EmailConfig
import com.stripe.android.uicore.elements.NameConfig
import com.stripe.android.uicore.elements.PhoneNumberController
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds

internal class SignUpViewModel @Inject constructor(
private val args: LinkActivityContract.Args,
private val linkAccountManager: LinkAccountManager,
private val linkEventsReporter: LinkEventsReporter,
private val logger: Logger
) : ViewModel() {
internal var navController: NavHostController? = null
val emailController = EmailConfig.createController(
initialValue = args.configuration.customerInfo.email
)
val phoneNumberController = PhoneNumberController.createPhoneNumberController(
initialValue = args.configuration.customerInfo.phone.orEmpty(),
initiallySelectedCountryCode = args.configuration.customerInfo.billingCountryCode
)
val nameController = NameConfig.createController(
initialValue = args.configuration.customerInfo.name
)
private val _state = MutableStateFlow(
value = SignUpScreenState(
signUpEnabled = false
)
)

val state: StateFlow<SignUpScreenState> = _state

private val requiresNameCollection: Boolean
get() {
val countryCode = when (val stripeIntent = args.configuration.stripeIntent) {
is PaymentIntent -> stripeIntent.countryCode
is SetupIntent -> stripeIntent.countryCode
}
return countryCode != CountryCode.US.value
}

init {
viewModelScope.launch {
signUpEnabledListener()
}
viewModelScope.launch {
emailListener()
}
linkEventsReporter.onSignupFlowPresented()
}

private suspend fun signUpEnabledListener() {
combine(
flow = nameController.fieldState.map {
if (requiresNameCollection) {
it.isValid()
} else {
true
}
},
flow2 = emailController.fieldState.map { it.isValid() },
flow3 = phoneNumberController.isComplete
) { nameComplete, emailComplete, phoneComplete ->
nameComplete && emailComplete && phoneComplete
}.collectLatest { formValid ->
updateState { it.copy(signUpEnabled = formValid) }
}
}

private suspend fun emailListener() {
emailController.formFieldValue.mapLatest { entry ->
entry.takeIf { it.isComplete }?.value
}.collectLatest { email ->
delay(LOOKUP_DEBOUNCE)
if (email != null) {
updateSignUpState(SignUpState.VerifyingEmail)
lookupConsumerEmail(email)
} else {
updateSignUpState(SignUpState.InputtingPrimaryField)
}
}
}

fun onSignUpClick() {
clearError()
viewModelScope.launch {
linkAccountManager.signUp(
email = emailController.fieldValue.value,
phone = phoneNumberController.fieldValue.value,
country = phoneNumberController.getCountryCode(),
name = nameController.fieldValue.value,
consentAction = SignUpConsentAction.Implied
).fold(
onSuccess = {
onAccountFetched(it)
linkEventsReporter.onSignupCompleted()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(for a follow up PR) should the analytics be handled as part of link account manager instead of here? I'd expect the analytics to be the same wherever linkAccountManager.signUp is called from

},
onFailure = {
onError(it)
linkEventsReporter.onSignupFailure(error = it)
}
)
}
}

private fun onAccountFetched(linkAccount: LinkAccount?) {
if (linkAccount?.isVerified == true) {
navController?.popBackStack(LinkScreen.Wallet.route, inclusive = false)
} else {
navController?.navigate(LinkScreen.Verification.route)
// The sign up screen stays in the back stack.
// Clean up the state in case the user comes back.
emailController.onValueChange("")
}
}

private suspend fun lookupConsumerEmail(email: String) {
clearError()
linkAccountManager.lookupConsumer(email).fold(
onSuccess = {
if (it != null) {
onAccountFetched(it)
Comment on lines +145 to +148
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be done before we even get to this screen? It seems like this would always navigate away from the sign up screen, so I wonder if we should do it while loading or something instead

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be checked on the LinkVM. The user will not get to this screen if we find an account. The first lookup here will return null (most likely) since we already checked beforehand.

This VM also listens to email changes. If the lookup is successful after an email change, we would navigate to the next screen as well. This is not likely and I'm certain it won't be the case since we don't have persistent log in anymore. I decided to keep in case we change our mind to add persistence later on. The old link implementation had persistence and did a lookup anytime the email changed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, it sounds like this check is unnecessary right now then, is that right? If we add persistence back, we should add this code snippet back. But I think we should avoid adding this unless it's needed.

} else {
updateSignUpState(SignUpState.InputtingRemainingFields)
linkEventsReporter.onSignupStarted()
}
},
onFailure = {
updateSignUpState(SignUpState.InputtingPrimaryField)
onError(it)
}
)
}

private fun onError(error: Throwable) {
logger.error("Error: ", error)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this logger indicate somehow that the error is coming from within the signup VM?

updateState {
it.copy(
errorMessage = when (val errorMessage = error.getErrorMessage()) {
is ErrorMessage.FromResources -> {
errorMessage.stringResId.resolvableString
}
is ErrorMessage.Raw -> {
errorMessage.errorMessage.resolvableString
}
}
)
}
}

private fun clearError() {
updateState { it.copy(errorMessage = null) }
}

private fun updateState(produceValue: (SignUpScreenState) -> SignUpScreenState) {
_state.update(produceValue)
}

private fun updateSignUpState(signUpState: SignUpState) {
updateState { old ->
old.copy(signUpState = signUpState)
}
}

companion object {
// How long to wait before triggering a call to lookup the email
internal val LOOKUP_DEBOUNCE = 1.seconds
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.stripe.android.link.account
import com.stripe.android.link.LinkPaymentDetails
import com.stripe.android.link.model.AccountStatus
import com.stripe.android.link.model.LinkAccount
import com.stripe.android.link.ui.inline.SignUpConsentAction
import com.stripe.android.link.ui.inline.UserInput
import com.stripe.android.model.ConsumerPaymentDetails
import com.stripe.android.model.ConsumerSession
Expand All @@ -21,6 +22,7 @@ internal open class FakeLinkAccountManager : LinkAccountManager {
override val accountStatus: Flow<AccountStatus> = _accountStatus

var lookupConsumerResult: Result<LinkAccount?> = Result.success(null)
var signUpResult: Result<LinkAccount> = Result.success(LinkAccount(ConsumerSession("", "", "", "")))
var signInWithUserInputResult: Result<LinkAccount> = Result.success(LinkAccount(ConsumerSession("", "", "", "")))
var logOutResult: Result<ConsumerSession> = Result.success(ConsumerSession("", "", "", ""))
var createCardPaymentDetailsResult: Result<LinkPaymentDetails> = Result.success(
Expand All @@ -47,6 +49,16 @@ internal open class FakeLinkAccountManager : LinkAccountManager {
return lookupConsumerResult
}

override suspend fun signUp(
email: String,
phone: String,
country: String,
name: String?,
consentAction: SignUpConsentAction
): Result<LinkAccount> {
return signUpResult
}

override suspend fun signInWithUserInput(userInput: UserInput): Result<LinkAccount> {
return signInWithUserInputResult
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.stripe.android.link.analytics

internal open class FakeLinkEventsReporter : LinkEventsReporter {
var calledCount = 0
override fun onInvalidSessionState(state: LinkEventsReporter.SessionState) {
throw NotImplementedError()
}

override fun onInlineSignupCheckboxChecked() {
throw NotImplementedError()
}

override fun onSignupFlowPresented() {
throw NotImplementedError()
}

override fun onSignupStarted(isInline: Boolean) {
throw NotImplementedError()
}

override fun onSignupCompleted(isInline: Boolean) {
throw NotImplementedError()
}

override fun onSignupFailure(isInline: Boolean, error: Throwable) {
throw NotImplementedError()
}

override fun onAccountLookupFailure(error: Throwable) {
throw NotImplementedError()
}

override fun onPopupShow() {
throw NotImplementedError()
}

override fun onPopupSuccess() {
throw NotImplementedError()
}

override fun onPopupCancel() {
throw NotImplementedError()
}

override fun onPopupError(error: Throwable) {
throw NotImplementedError()
}

override fun onPopupLogout() {
throw NotImplementedError()
}

override fun onPopupSkipped() {
throw NotImplementedError()
}
}
Loading
Loading