diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/ContactSupportBottomSheet.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/ContactSupportBottomSheet.kt new file mode 100644 index 0000000000..b57e0fc4f5 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/ContactSupportBottomSheet.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.verificationcode + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.dismissWithAnimation +import org.signal.core.util.LinkActions +import org.signal.registration.R +import org.signal.registration.RegistrationDependencies + +/** + * Bottom sheet shown during registration when the user is having trouble entering their verification code. Offers + * troubleshooting steps and a way to contact support, mirroring the old app-module ContactSupportBottomSheetFragment. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactSupportBottomSheet(onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + val context = LocalContext.current + val supportEmailSubject = stringResource(R.string.VerificationCodeScreen__contact_support_email_subject) + val supportCenterUrl = stringResource(R.string.VerificationCodeScreen__support_center_url) + + BottomSheets.BottomSheet( + onDismissRequest = { sheetState.dismissWithAnimation(scope, onComplete = onDismiss) }, + sheetState = sheetState + ) { + ContactSupportBottomSheetContent( + onTroubleshootingStepsClick = { + LinkActions.openUrl(context, supportCenterUrl) { + Toast.makeText(context, R.string.LinkActions_error_no_browser_found, Toast.LENGTH_SHORT).show() + } + }, + onContactSupportClick = { + RegistrationDependencies.get().contactSupportCallback?.invoke(context, supportEmailSubject) + } + ) + } +} + +@Composable +private fun ContactSupportBottomSheetContent( + onTroubleshootingStepsClick: () -> Unit, + onContactSupportClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)) { + append(stringResource(R.string.VerificationCodeScreen__support_bottom_sheet_title)) + } + }, + modifier = Modifier.padding(8.dp) + ) + Text( + text = stringResource(R.string.VerificationCodeScreen__support_bottom_sheet_body_suggestions), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(8.dp) + ) + Text( + text = buildCallToActionString(onTroubleshootingStepsClick, onContactSupportClick), + modifier = Modifier.padding(8.dp) + ) + } +} + +@Composable +private fun buildCallToActionString( + onTroubleshootingStepsClick: () -> Unit, + onContactSupportClick: () -> Unit +) = buildAnnotatedString { + val troubleshootingStepsString = stringResource(R.string.VerificationCodeScreen__support_bottom_sheet_cta_troubleshooting_steps_substring) + val contactSupportString = stringResource(R.string.VerificationCodeScreen__support_bottom_sheet_cta_contact_support_substring) + val completeString = stringResource(R.string.VerificationCodeScreen__support_bottom_sheet_body_call_to_action, troubleshootingStepsString, contactSupportString) + + val troubleshootingStartIndex = completeString.indexOf(troubleshootingStepsString) + val troubleshootingEndIndex = troubleshootingStartIndex + troubleshootingStepsString.length + val contactSupportStartIndex = completeString.indexOf(contactSupportString) + val contactSupportEndIndex = contactSupportStartIndex + contactSupportString.length + + val bodyStyle = SpanStyle(color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Normal) + val linkStyles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)) + + withStyle(bodyStyle) { + append(completeString.substring(0, troubleshootingStartIndex)) + } + withLink(LinkAnnotation.Clickable(tag = "troubleshooting", styles = linkStyles) { onTroubleshootingStepsClick() }) { + append(troubleshootingStepsString) + } + withStyle(bodyStyle) { + append(completeString.substring(troubleshootingEndIndex, contactSupportStartIndex)) + } + withLink(LinkAnnotation.Clickable(tag = "contact_support", styles = linkStyles) { onContactSupportClick() }) { + append(contactSupportString) + } + withStyle(bodyStyle) { + append(completeString.substring(contactSupportEndIndex)) + } +} + +@Preview +@Composable +private fun ContactSupportBottomSheetPreview() { + Previews.BottomSheetPreview { + ContactSupportBottomSheetContent( + onTroubleshootingStepsClick = {}, + onContactSupportClick = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt index 28213d4d7c..377f513b4d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt @@ -137,6 +137,12 @@ fun VerificationCodeScreen( onEvent(VerificationCodeScreenEvents.Foregrounded) } + if (state.showContactSupportSheet) { + ContactSupportBottomSheet( + onDismiss = { onEvent(VerificationCodeScreenEvents.DismissContactSupport) } + ) + } + Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, modifier = modifier diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt index b8fcdcd117..02c077ce49 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt @@ -39,6 +39,8 @@ sealed class VerificationCodeScreenEvents { data object HavingTrouble : VerificationCodeScreenEvents() + data object DismissContactSupport : VerificationCodeScreenEvents() + data object ConsumeInnerOneTimeEvent : VerificationCodeScreenEvents() /** diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt index af6bc2f960..e6351847e8 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt @@ -18,9 +18,10 @@ data class VerificationCodeState( val autoFillCode: String? = null, val digits: List = List(CODE_LENGTH) { "" }, val focusedDigitIndex: Int = 0, + val showContactSupportSheet: Boolean = false, val oneTimeEvent: OneTimeEvent? = null ) { - override fun toString(): String = "VerificationCodeState(sessionMetadata=${sessionMetadata?.let { "present" }}, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, digitsEntered=${digits.count { it.isNotEmpty() }}, focusedDigitIndex=$focusedDigitIndex, oneTimeEvent=$oneTimeEvent)" + override fun toString(): String = "VerificationCodeState(sessionMetadata=${sessionMetadata?.let { "present" }}, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, digitsEntered=${digits.count { it.isNotEmpty() }}, focusedDigitIndex=$focusedDigitIndex, showContactSupportSheet=$showContactSupportSheet, oneTimeEvent=$oneTimeEvent)" /** * The full code as currently entered. Only meaningful when [isComplete] is true. diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt index 0c06daec94..5ab7cf7e81 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt @@ -127,7 +127,8 @@ class VerificationCodeViewModel( is VerificationCodeScreenEvents.WrongNumber -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) } is VerificationCodeScreenEvents.ResendSms -> applyResendCode(state, NetworkController.VerificationCodeTransport.SMS) is VerificationCodeScreenEvents.CallMe -> applyResendCode(state, NetworkController.VerificationCodeTransport.VOICE) - is VerificationCodeScreenEvents.HavingTrouble -> throw NotImplementedError("having trouble flow") // TODO [registration] - Having trouble flow + is VerificationCodeScreenEvents.HavingTrouble -> state.copy(showContactSupportSheet = true) + is VerificationCodeScreenEvents.DismissContactSupport -> state.copy(showContactSupportSheet = false) is VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent -> state.copy(oneTimeEvent = null) is VerificationCodeScreenEvents.CountdownTick -> applyCountdownTick(state) is VerificationCodeScreenEvents.Foregrounded -> applyForegrounded(state) diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 828631afa0..18673a7a8b 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -101,6 +101,20 @@ Registration failed. Please try again. Having trouble? + + Having trouble registering? + + • Make sure your phone has a cellular signal to receive your SMS or call\n • Confirm you can receive a phone call to the number\n • Check that you have entered your phone number correctly. + + For more information, please follow %1$s or %2$s + + these troubleshooting steps + + Contact Support + + Signal Registration - Verification Code for Android + + https://support.signal.org/