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

SDK-2447 Respect the order of components in B2C Prebuilt UI #268

Merged
merged 1 commit into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 88 additions & 99 deletions source/sdk/src/main/java/com/stytch/sdk/ui/b2c/screens/MainScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ import com.stytch.sdk.ui.b2c.AuthenticationActivity
import com.stytch.sdk.ui.b2c.data.ApplicationUIState
import com.stytch.sdk.ui.b2c.data.EventState
import com.stytch.sdk.ui.b2c.data.OAuthProvider
import com.stytch.sdk.ui.b2c.data.OTPMethods
import com.stytch.sdk.ui.b2c.data.OTPOptions
import com.stytch.sdk.ui.b2c.data.StytchProduct
import com.stytch.sdk.ui.b2c.data.StytchProductConfig
import com.stytch.sdk.ui.shared.components.BackButton
import com.stytch.sdk.ui.shared.components.DividerWithText
Expand Down Expand Up @@ -80,6 +78,8 @@ internal object MainScreen : AndroidScreen(), Parcelable {
sendSmsOtp = { viewModel.sendSmsOTP(it, productConfig.locale) },
sendWhatsAppOTP = { viewModel.sendWhatsAppOTP(it, productConfig.locale) },
exitWithoutAuthenticating = context::exitWithoutAuthenticating,
productComponents = viewModel.getProductComponents(productConfig.products),
tabTypes = viewModel.getTabTitleOrdering(productConfig.products, productConfig.otpOptions.methods),
)
}
}
Expand All @@ -95,33 +95,18 @@ private fun MainScreenComposable(
sendSmsOtp: (OTPOptions) -> Unit,
sendWhatsAppOTP: (OTPOptions) -> Unit,
exitWithoutAuthenticating: () -> Unit,
productComponents: List<ProductComponent>,
tabTypes: List<TabTypes>,
) {
val productConfig = LocalStytchProductConfig.current
val theme = LocalStytchTheme.current
val type = LocalStytchTypography.current
val hasButtons = productConfig.products.contains(StytchProduct.OAUTH)
val hasInput =
productConfig.products.any {
listOf(StytchProduct.OTP, StytchProduct.PASSWORDS, StytchProduct.EMAIL_MAGIC_LINKS).contains(it)
}
val hasEmail =
productConfig.products.any {
listOf(StytchProduct.EMAIL_MAGIC_LINKS, StytchProduct.PASSWORDS).contains(it)
} ||
productConfig.otpOptions.methods.contains(OTPMethods.EMAIL)
val hasDivider = hasButtons && hasInput
val tabTitles =
mutableListOf<String>().apply {
if (hasEmail) {
add(stringResource(id = R.string.email))
}
if (productConfig.products.contains(StytchProduct.OTP)) {
if (productConfig.otpOptions.methods.contains(OTPMethods.SMS)) {
add(stringResource(id = R.string.text))
}
if (productConfig.otpOptions.methods.contains(OTPMethods.WHATSAPP)) {
add(stringResource(id = R.string.whatsapp))
}
tabTypes.map {
when (it) {
TabTypes.EMAIL -> stringResource(id = R.string.email)
TabTypes.SMS -> stringResource(id = R.string.text)
TabTypes.WHATSAPP -> stringResource(id = R.string.whatsapp)
}
}
var selectedTabIndex by remember { mutableIntStateOf(0) }
Expand All @@ -134,87 +119,91 @@ private fun MainScreenComposable(
if (!theme.hideHeaderText) {
PageTitle(text = stringResource(id = R.string.sign_up_or_login))
}
if (productConfig.products.contains(StytchProduct.OAUTH)) {
productConfig.oAuthOptions.providers.map {
SocialLoginButton(
modifier =
Modifier
.padding(bottom = 12.dp)
.semantics {
contentDescription = semanticsOAuthButton
},
onClick = { onStartOAuthLogin(it, productConfig) },
iconDrawable = painterResource(id = it.iconDrawable),
iconDescription = stringResource(id = it.iconText),
text = stringResource(id = it.text),
)
}
}
if (hasDivider) {
DividerWithText(
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp),
text = stringResource(id = R.string.or),
)
}
if (hasInput && tabTitles.isNotEmpty()) { // sanity check
if (tabTitles.size > 1) {
val semanticTabs = stringResource(id = R.string.semantics_tabs)
TabRow(
selectedTabIndex = selectedTabIndex,
containerColor = Color(theme.backgroundColor),
modifier = Modifier.padding(bottom = 12.dp).semantics { contentDescription = semanticTabs },
indicator = { tabPositions ->
SecondaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
color = Color(theme.primaryTextColor),
productComponents.map {
when (it) {
ProductComponent.BUTTONS -> {
productConfig.oAuthOptions.providers.map {
SocialLoginButton(
modifier =
Modifier
.padding(bottom = 12.dp)
.semantics {
contentDescription = semanticsOAuthButton
},
onClick = { onStartOAuthLogin(it, productConfig) },
iconDrawable = painterResource(id = it.iconDrawable),
iconDescription = stringResource(id = it.iconText),
text = stringResource(id = it.text),
)
},
) {
tabTitles.forEachIndexed { index, title ->
Tab(
selected = index == selectedTabIndex,
onClick = { selectedTabIndex = index },
modifier = Modifier.height(48.dp),
}
}
ProductComponent.DIVIDER -> {
DividerWithText(
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp),
text = stringResource(id = R.string.or),
)
}
ProductComponent.INPUTS -> {
if (tabTitles.size > 1) {
val semanticTabs = stringResource(id = R.string.semantics_tabs)
TabRow(
selectedTabIndex = selectedTabIndex,
containerColor = Color(theme.backgroundColor),
modifier = Modifier.padding(bottom = 12.dp).semantics { contentDescription = semanticTabs },
indicator = { tabPositions ->
SecondaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
color = Color(theme.primaryTextColor),
)
},
) {
Text(
text = title,
style =
type.body2.copy(
color = Color(theme.primaryTextColor),
lineHeight = 48.sp,
),
)
tabTitles.forEachIndexed { index, title ->
Tab(
selected = index == selectedTabIndex,
onClick = { selectedTabIndex = index },
modifier = Modifier.height(48.dp),
) {
Text(
text = title,
style =
type.body2.copy(
color = Color(theme.primaryTextColor),
lineHeight = 48.sp,
),
)
}
}
}
}
when (tabTitles[selectedTabIndex]) {
stringResource(id = R.string.email) ->
EmailEntry(
emailState = emailState,
onEmailAddressChanged = onEmailAddressChanged,
onEmailAddressSubmit = { onEmailAddressSubmit(productConfig) },
)
stringResource(id = R.string.text) ->
PhoneEntry(
countryCode = phoneState.countryCode,
onCountryCodeChanged = onCountryCodeChanged,
phoneNumber = phoneState.phoneNumber,
onPhoneNumberChanged = onPhoneNumberChanged,
onPhoneNumberSubmit = { sendSmsOtp(productConfig.otpOptions) },
statusText = phoneState.error,
)
stringResource(id = R.string.whatsapp) ->
PhoneEntry(
countryCode = phoneState.countryCode,
onCountryCodeChanged = onCountryCodeChanged,
phoneNumber = phoneState.phoneNumber,
onPhoneNumberChanged = onPhoneNumberChanged,
onPhoneNumberSubmit = { sendWhatsAppOTP(productConfig.otpOptions) },
statusText = phoneState.error,
)
else -> Text(stringResource(id = R.string.misconfigured_otp))
}
}
}
when (tabTitles[selectedTabIndex]) {
stringResource(id = R.string.email) ->
EmailEntry(
emailState = emailState,
onEmailAddressChanged = onEmailAddressChanged,
onEmailAddressSubmit = { onEmailAddressSubmit(productConfig) },
)
stringResource(id = R.string.text) ->
PhoneEntry(
countryCode = phoneState.countryCode,
onCountryCodeChanged = onCountryCodeChanged,
phoneNumber = phoneState.phoneNumber,
onPhoneNumberChanged = onPhoneNumberChanged,
onPhoneNumberSubmit = { sendSmsOtp(productConfig.otpOptions) },
statusText = phoneState.error,
)
stringResource(id = R.string.whatsapp) ->
PhoneEntry(
countryCode = phoneState.countryCode,
onCountryCodeChanged = onCountryCodeChanged,
phoneNumber = phoneState.phoneNumber,
onPhoneNumberChanged = onPhoneNumberChanged,
onPhoneNumberSubmit = { sendWhatsAppOTP(productConfig.otpOptions) },
statusText = phoneState.error,
)
else -> Text(stringResource(id = R.string.misconfigured_otp))
}
}
uiState.genericErrorMessage?.let {
FormFieldStatus(text = it, isError = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch

internal enum class ProductComponent {
BUTTONS,
INPUTS,
DIVIDER,
}

internal enum class TabTypes {
EMAIL,
SMS,
WHATSAPP,
}

internal class MainScreenViewModel(
private val savedStateHandle: SavedStateHandle,
private val stytchClient: StytchClient,
Expand All @@ -48,6 +60,57 @@ internal class MainScreenViewModel(
private val _eventFlow = MutableSharedFlow<EventState>()
val eventFlow = _eventFlow.asSharedFlow()

fun getProductComponents(products: List<StytchProduct>): List<ProductComponent> {
val hasButtons = products.contains(StytchProduct.OAUTH)
val hasInput =
products.any {
listOf(StytchProduct.OTP, StytchProduct.PASSWORDS, StytchProduct.EMAIL_MAGIC_LINKS).contains(it)
}
val hasDivider = hasButtons && hasInput
return mutableListOf<ProductComponent>()
.apply {
products.forEachIndexed { index, product ->
if (hasDivider && index > 0) {
add(ProductComponent.DIVIDER)
}
if (product == StytchProduct.OAUTH) {
add(ProductComponent.BUTTONS)
}
if (product == StytchProduct.PASSWORDS ||
product == StytchProduct.EMAIL_MAGIC_LINKS ||
product == StytchProduct.OTP
) {
add(ProductComponent.INPUTS)
}
}
}.toSet()
.toList()
}

fun getTabTitleOrdering(
products: List<StytchProduct>,
otpMethods: List<OTPMethods>,
): List<TabTypes> {
val hasEmail =
products.any {
listOf(StytchProduct.EMAIL_MAGIC_LINKS, StytchProduct.PASSWORDS).contains(it)
}
return mutableListOf<TabTypes>()
.apply {
if (hasEmail) {
add(TabTypes.EMAIL)
}
otpMethods.forEach { method ->
when (method) {
OTPMethods.SMS -> add(TabTypes.SMS)
OTPMethods.EMAIL -> add(TabTypes.EMAIL)
OTPMethods.WHATSAPP -> add(TabTypes.WHATSAPP)
}
}
}.toSet()
.toList()
}

fun onStartOAuthLogin(
context: ComponentActivity,
provider: OAuthProvider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -520,4 +520,58 @@ internal class MainScreenViewModelTest {
assert(!viewModel.uiState.value.showLoadingDialog)
assert(viewModel.uiState.value.phoneNumberState.error == "Something went wrong")
}

@Test
fun `getProductComponents returns the expected order`() {
var given = listOf(StytchProduct.EMAIL_MAGIC_LINKS, StytchProduct.OAUTH)
var expected = listOf(ProductComponent.INPUTS, ProductComponent.DIVIDER, ProductComponent.BUTTONS)
assert(viewModel.getProductComponents(given) == expected)

given = listOf(StytchProduct.OTP, StytchProduct.OAUTH)
expected = listOf(ProductComponent.INPUTS, ProductComponent.DIVIDER, ProductComponent.BUTTONS)
assert(viewModel.getProductComponents(given) == expected)

given = listOf(StytchProduct.PASSWORDS, StytchProduct.OAUTH)
expected = listOf(ProductComponent.INPUTS, ProductComponent.DIVIDER, ProductComponent.BUTTONS)
assert(viewModel.getProductComponents(given) == expected)

given = listOf(StytchProduct.PASSWORDS, StytchProduct.EMAIL_MAGIC_LINKS, StytchProduct.OAUTH)
expected = listOf(ProductComponent.INPUTS, ProductComponent.DIVIDER, ProductComponent.BUTTONS)
assert(viewModel.getProductComponents(given) == expected)

given = listOf(StytchProduct.OAUTH, StytchProduct.EMAIL_MAGIC_LINKS)
expected = listOf(ProductComponent.BUTTONS, ProductComponent.DIVIDER, ProductComponent.INPUTS)
assert(viewModel.getProductComponents(given) == expected)

given = listOf(StytchProduct.OAUTH)
expected = listOf(ProductComponent.BUTTONS)
assert(viewModel.getProductComponents(given) == expected)

given = listOf(StytchProduct.EMAIL_MAGIC_LINKS)
expected = listOf(ProductComponent.INPUTS)
assert(viewModel.getProductComponents(given) == expected)
}

@Test
fun `getTabTitleOrdering returns the expected order`() {
var givenProducts = listOf(StytchProduct.EMAIL_MAGIC_LINKS, StytchProduct.PASSWORDS)
var givenOTPMethods = emptyList<OTPMethods>()
var expected = listOf(TabTypes.EMAIL)
assert(viewModel.getTabTitleOrdering(givenProducts, givenOTPMethods) == expected)

givenProducts = listOf(StytchProduct.OTP)
givenOTPMethods = listOf(OTPMethods.EMAIL)
expected = listOf(TabTypes.EMAIL)
assert(viewModel.getTabTitleOrdering(givenProducts, givenOTPMethods) == expected)

givenProducts = listOf(StytchProduct.OTP)
givenOTPMethods = listOf(OTPMethods.WHATSAPP, OTPMethods.EMAIL, OTPMethods.SMS)
expected = listOf(TabTypes.WHATSAPP, TabTypes.EMAIL, TabTypes.SMS)
assert(viewModel.getTabTitleOrdering(givenProducts, givenOTPMethods) == expected)

givenProducts = listOf(StytchProduct.PASSWORDS, StytchProduct.OTP)
givenOTPMethods = listOf(OTPMethods.WHATSAPP, OTPMethods.SMS)
expected = listOf(TabTypes.EMAIL, TabTypes.WHATSAPP, TabTypes.SMS)
assert(viewModel.getTabTitleOrdering(givenProducts, givenOTPMethods) == expected)
}
}