Skip to content

Commit

Permalink
link sign up viewmodel
Browse files Browse the repository at this point in the history
  • Loading branch information
toluo-stripe committed Oct 7, 2024
1 parent 76de1fc commit 9851c6a
Show file tree
Hide file tree
Showing 9 changed files with 745 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.stripe.android.model.PaymentMethodCreateParams
import com.stripe.android.payments.core.analytics.ErrorReporter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject

Expand Down Expand Up @@ -160,7 +161,7 @@ internal class LinkAccountManager @Inject constructor(
/**
* Registers the user for a new Link account.
*/
private suspend fun signUp(
suspend fun signUp(
email: String,
phone: String,
country: String,
Expand Down Expand Up @@ -217,6 +218,17 @@ internal class LinkAccountManager @Inject constructor(
}
}

/**
* Whether the user has logged out from any account.
*/
suspend fun hasUserLoggedOut(email: String?): Boolean {
email ?: return true
val linkAccount = linkAccount.value ?: return true
if (email != linkAccount.email) return true
val currentStatus = accountStatus.first()
return currentStatus == AccountStatus.SignedOut
}

private fun setAccount(
consumerSession: ConsumerSession,
publishableKey: String?,
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.stripe.android.link.ui.signup

import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.uicore.elements.PhoneNumberController
import com.stripe.android.uicore.elements.TextFieldController

sealed interface SignUpScreenState {
data class Content(
val emailController: TextFieldController,
val phoneNumberController: PhoneNumberController,
val nameController: TextFieldController,
val signUpEnabled: Boolean,
val signUpState: SignUpState = SignUpState.InputtingPrimaryField,
val errorMessage: ResolvableString? = null
) : SignUpScreenState

data object Loading : SignUpScreenState
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
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.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.flatMapLatest
import kotlinx.coroutines.flow.flowOf
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
private val _state = MutableStateFlow<SignUpScreenState>(SignUpScreenState.Loading)

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 {
loadScreen()
}
viewModelScope.launch {
signUpEnabledListener()
}
viewModelScope.launch {
emailListener()
}
linkEventsReporter.onSignupFlowPresented()
}

private suspend fun loadScreen() {
val isLoggedOut = linkAccountManager.hasUserLoggedOut(args.configuration.customerInfo.email)
val newState = SignUpScreenState.Content(
emailController = EmailConfig.createController(
initialValue = args.configuration.customerInfo.email.takeUnless { isLoggedOut }
),
phoneNumberController = PhoneNumberController.createPhoneNumberController(
initialValue = args.configuration.customerInfo.phone.takeUnless { isLoggedOut }.orEmpty(),
initiallySelectedCountryCode = args.configuration.customerInfo.billingCountryCode,
),
nameController = NameConfig.createController(
initialValue = args.configuration.customerInfo.name.takeUnless { isLoggedOut }
),
signUpEnabled = false
)
_state.emit(newState)
}

private suspend fun signUpEnabledListener() {
_state.flatMapLatest { state ->
if (state !is SignUpScreenState.Content) return@flatMapLatest flowOf()
combine(
flow = state.nameController.fieldState.map {
if (requiresNameCollection) {
it.isValid()
} else {
true
}
},
flow2 = state.emailController.fieldState.map { it.isValid() },
flow3 = state.phoneNumberController.isComplete
) { nameComplete, emailComplete, phoneComplete ->
nameComplete && emailComplete && phoneComplete
}
}.collectLatest { formValid ->
updateSignUpEnabled(formValid)
}
}

private suspend fun emailListener() {
_state.flatMapLatest { state ->
if (state !is SignUpScreenState.Content) return@flatMapLatest flowOf()
state.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 {
val state = (_state.value as? SignUpScreenState.Content) ?: return@launch
linkAccountManager.signUp(
email = state.emailController.fieldValue.value,
phone = state.phoneNumberController.fieldValue.value,
country = state.phoneNumberController.getCountryCode(),
name = state.nameController.fieldValue.value,
consentAction = SignUpConsentAction.Implied
).fold(
onSuccess = {
onAccountFetched(it)
linkEventsReporter.onSignupCompleted()
},
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.
(_state.value as SignUpScreenState.Content).emailController.onValueChange("")
}
}

private suspend fun lookupConsumerEmail(email: String) {
clearError()
linkAccountManager.lookupConsumer(email).fold(
onSuccess = {
if (it != null) {
onAccountFetched(it)
} else {
updateSignUpState(SignUpState.InputtingRemainingFields)
linkEventsReporter.onSignupStarted()
}
},
onFailure = {
updateSignUpState(SignUpState.InputtingPrimaryField)
onError(it)
}
)
}

private fun onError(error: Throwable) {
logger.error("Error: ", error)
updateErrorMessage(
error = when (val errorMessage = error.getErrorMessage()) {
is ErrorMessage.FromResources -> {
errorMessage.stringResId.resolvableString
}
is ErrorMessage.Raw -> {
errorMessage.errorMessage.resolvableString
}
}
)
}

private fun clearError() {
updateErrorMessage(null)
}

private fun updateSignUpEnabled(enabled: Boolean) {
_state.update { old ->
if (old !is SignUpScreenState.Content) return@update old
old.copy(signUpEnabled = enabled)
}
}

private fun updateSignUpState(signUpState: SignUpState) {
_state.update { old ->
if (old !is SignUpScreenState.Content) return@update old
old.copy(signUpState = signUpState)
}
}

private fun updateErrorMessage(error: ResolvableString?) {
_state.update { old ->
if (old !is SignUpScreenState.Content) return@update old
old.copy(errorMessage = error)
}
}

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 @@ -430,6 +430,49 @@ class LinkAccountManagerTest {
assertThat(accountManager.linkAccount.value).isNotNull()
}

@Test
fun `hasUserLoggedOut is true when email is null`() = runSuspendTest {
val accountManager = accountManager()

assertThat(accountManager.hasUserLoggedOut(null)).isTrue()
}

@Test
fun `hasUserLoggedOut is true when email does not match link account`() = runSuspendTest {
val accountManager = accountManager()

accountManager.setLinkAccountFromLookupResult(
mockConsumerSessionLookup.copy(
consumerSession = mockConsumerSession.copy(
emailAddress = "${EMAIL}m"
)
),
startSession = true,
)

assertThat(accountManager.hasUserLoggedOut(EMAIL)).isTrue()
}

@Test
fun `hasUserLoggedOut is true when there is no link account`() = runSuspendTest {
val accountManager = accountManager()

assertThat(accountManager.hasUserLoggedOut(EMAIL)).isTrue()
}

@Test
fun `hasUserLoggedOut is false when there is a link account with the same email and valid status`() =
runSuspendTest {
val accountManager = accountManager()

accountManager.setLinkAccountFromLookupResult(
mockConsumerSessionLookup,
startSession = true,
)

assertThat(accountManager.hasUserLoggedOut(EMAIL)).isFalse()
}

private fun runSuspendTest(testBody: suspend TestScope.() -> Unit) = runTest {
setupRepository()
testBody()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ private open class FakeLinkEventsReporter : LinkEventsReporter {
throw NotImplementedError()
}

override fun onSignupFlowPresented() {
throw NotImplementedError()
}

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

0 comments on commit 9851c6a

Please sign in to comment.