Skip to content

Commit 297d924

Browse files
WIP.
1 parent 76af5de commit 297d924

File tree

6 files changed

+453
-303
lines changed

6 files changed

+453
-303
lines changed

paymentsheet/res/values/strings.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
<!-- Title for the update card screen in Link native -->
5050
<string name="stripe_link_update_card_title">Update card</string>
5151
<!-- Instructs the user to enter the code sent to their phone number in order to login to Link -->
52-
<string name="stripe_link_verification_message">Enter the code sent to %s to use your saved information.</string>
52+
<string name="stripe_link_verification_message_short">Enter the code sent to %s.</string>
5353
<!-- Menu option to update card on the wallet screen on Link native -->
5454
<string name="stripe_link_wallet_menu_action_update_card">Update card</string>
5555
<!-- Title of the logout action. -->
@@ -216,7 +216,7 @@
216216
<!-- Text on a screen that indicates a payment has failed -->
217217
<string name="stripe_upi_polling_payment_failed_title">Payment failed</string>
218218
<!-- Title for a button that allows the user to use a different email in the signup flow. -->
219-
<string name="stripe_verification_change_email">Change email</string>
219+
<string name="stripe_verification_change_email_new">Not you?</string>
220220
<!-- Text of a notification shown to the user when a login code is successfully sent via SMS. -->
221221
<string name="stripe_verification_code_sent">Code sent</string>
222222
<!-- Two factor authentication screen heading -->
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
package com.stripe.android.link.ui.verification
2+
3+
import android.widget.Toast
4+
import androidx.compose.animation.AnimatedVisibility
5+
import androidx.compose.foundation.Image
6+
import androidx.compose.foundation.clickable
7+
import androidx.compose.foundation.layout.Arrangement
8+
import androidx.compose.foundation.layout.Box
9+
import androidx.compose.foundation.layout.Column
10+
import androidx.compose.foundation.layout.ColumnScope
11+
import androidx.compose.foundation.layout.Row
12+
import androidx.compose.foundation.layout.Spacer
13+
import androidx.compose.foundation.layout.fillMaxWidth
14+
import androidx.compose.foundation.layout.padding
15+
import androidx.compose.foundation.layout.size
16+
import androidx.compose.foundation.layout.width
17+
import androidx.compose.material.CircularProgressIndicator
18+
import androidx.compose.material.ContentAlpha
19+
import androidx.compose.material.Icon
20+
import androidx.compose.material.IconButton
21+
import androidx.compose.material.MaterialTheme
22+
import androidx.compose.material.Text
23+
import androidx.compose.runtime.Composable
24+
import androidx.compose.runtime.LaunchedEffect
25+
import androidx.compose.runtime.remember
26+
import androidx.compose.ui.Alignment
27+
import androidx.compose.ui.Modifier
28+
import androidx.compose.ui.draw.alpha
29+
import androidx.compose.ui.focus.FocusRequester
30+
import androidx.compose.ui.platform.LocalContext
31+
import androidx.compose.ui.platform.LocalFocusManager
32+
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
33+
import androidx.compose.ui.platform.testTag
34+
import androidx.compose.ui.res.painterResource
35+
import androidx.compose.ui.res.stringResource
36+
import androidx.compose.ui.text.style.TextAlign
37+
import androidx.compose.ui.text.style.TextOverflow
38+
import androidx.compose.ui.unit.dp
39+
import com.stripe.android.link.theme.StripeThemeForLink
40+
import com.stripe.android.link.theme.linkColors
41+
import com.stripe.android.link.theme.linkShapes
42+
import com.stripe.android.link.ui.ErrorText
43+
import com.stripe.android.link.ui.ScrollableTopLevelColumn
44+
import com.stripe.android.link.utils.LINK_DEFAULT_ANIMATION_DELAY_MILLIS
45+
import com.stripe.android.paymentsheet.R
46+
import com.stripe.android.uicore.elements.OTPElement
47+
import com.stripe.android.uicore.elements.OTPElementUI
48+
import kotlinx.coroutines.delay
49+
50+
/**
51+
* Common verification body content used in [VerificationScreen] and [VerificationDialog].
52+
*/
53+
@Composable
54+
internal fun VerificationBody(
55+
state: VerificationViewState,
56+
otpElement: OTPElement,
57+
onBack: () -> Unit,
58+
onFocusRequested: () -> Unit,
59+
didShowCodeSentNotification: () -> Unit,
60+
onChangeEmailClick: () -> Unit,
61+
onResendCodeClick: () -> Unit,
62+
) {
63+
val context = LocalContext.current
64+
val focusManager = LocalFocusManager.current
65+
val focusRequester: FocusRequester = remember { FocusRequester() }
66+
val keyboardController = LocalSoftwareKeyboardController.current
67+
68+
LaunchedEffect(state.isProcessing) {
69+
if (state.isProcessing) {
70+
focusManager.clearFocus(true)
71+
keyboardController?.hide()
72+
}
73+
}
74+
75+
LaunchedEffect(state.requestFocus) {
76+
if (state.requestFocus) {
77+
delay(LINK_DEFAULT_ANIMATION_DELAY_MILLIS)
78+
focusRequester.requestFocus()
79+
keyboardController?.show()
80+
onFocusRequested()
81+
}
82+
}
83+
84+
LaunchedEffect(state.didSendNewCode) {
85+
if (state.didSendNewCode) {
86+
Toast.makeText(context, R.string.stripe_verification_code_sent, Toast.LENGTH_SHORT).show()
87+
didShowCodeSentNotification()
88+
}
89+
}
90+
91+
ContentWrapper(
92+
isDialog = state.isDialog,
93+
onBackClicked = {
94+
focusManager.clearFocus()
95+
onBack()
96+
}
97+
) {
98+
Header(
99+
isDialog = state.isDialog
100+
)
101+
102+
Spacer(modifier = Modifier.size(8.dp))
103+
104+
Text(
105+
text = stringResource(R.string.stripe_link_verification_message_short, state.redactedPhoneNumber),
106+
modifier = Modifier
107+
.testTag(VERIFICATION_SUBTITLE_TAG)
108+
.fillMaxWidth(),
109+
textAlign = TextAlign.Companion.Center,
110+
style = MaterialTheme.typography.body1,
111+
color = MaterialTheme.colors.onSecondary
112+
)
113+
114+
Spacer(modifier = Modifier.size(24.dp))
115+
116+
StripeThemeForLink {
117+
OTPElementUI(
118+
enabled = !state.isProcessing,
119+
element = otpElement,
120+
middleSpacing = 8.dp,
121+
boxSpacing = 8.dp,
122+
otpInputPlaceholder = " ",
123+
boxShape = MaterialTheme.linkShapes.large,
124+
modifier = Modifier
125+
// 48dp per OTP box plus 8dp per space
126+
.width(328.dp)
127+
.testTag(VERIFICATION_OTP_TAG),
128+
colors = MaterialTheme.linkColors.otpElementColors,
129+
focusRequester = focusRequester
130+
)
131+
}
132+
133+
AnimatedVisibility(visible = state.errorMessage != null) {
134+
ErrorText(
135+
text = state.errorMessage?.resolve(LocalContext.current).orEmpty(),
136+
modifier = Modifier
137+
.padding(top = 16.dp)
138+
.testTag(VERIFICATION_ERROR_TAG)
139+
.fillMaxWidth()
140+
)
141+
}
142+
143+
Spacer(modifier = Modifier.size(36.dp))
144+
ResendCodeButton(
145+
isProcessing = state.isProcessing,
146+
isSendingNewCode = state.isSendingNewCode,
147+
onClick = onResendCodeClick,
148+
)
149+
150+
if (state.isDialog.not()) {
151+
Spacer(modifier = Modifier.size(24.dp))
152+
ChangeEmailRow(
153+
email = state.email,
154+
isProcessing = state.isProcessing,
155+
onChangeEmailClick = onChangeEmailClick,
156+
)
157+
}
158+
159+
Spacer(modifier = Modifier.size(12.dp))
160+
}
161+
}
162+
163+
/**
164+
* A wrapper for the content of the verification screen.
165+
*
166+
* @param isDialog whether the screen is displayed as a dialog or not
167+
* @param content the content to be displayed
168+
*/
169+
@Composable
170+
private fun ContentWrapper(
171+
isDialog: Boolean,
172+
onBackClicked: () -> Unit,
173+
content: @Composable ColumnScope.() -> Unit
174+
) {
175+
if (isDialog) {
176+
Box {
177+
IconButton(
178+
modifier = Modifier
179+
// - IconButton ensures a 48.dp touch target for accessibility targets.
180+
// - The dialog padding is 24.dp.
181+
// - The icon is 16.dp
182+
.padding(12.dp)
183+
.align(Alignment.TopEnd)
184+
.testTag(VERIFICATION_HEADER_BUTTON_TAG),
185+
onClick = onBackClicked
186+
) {
187+
Icon(
188+
189+
modifier = Modifier.size(16.dp),
190+
painter = painterResource(R.drawable.stripe_link_close),
191+
contentDescription = stringResource(com.stripe.android.R.string.stripe_cancel)
192+
)
193+
}
194+
Column(
195+
modifier = Modifier.padding(24.dp),
196+
horizontalAlignment = Alignment.Companion.CenterHorizontally,
197+
content = content
198+
)
199+
}
200+
} else {
201+
ScrollableTopLevelColumn(content = content)
202+
}
203+
}
204+
205+
@Composable
206+
private fun Header(
207+
isDialog: Boolean,
208+
) {
209+
if (isDialog) {
210+
Row(
211+
modifier = Modifier
212+
.fillMaxWidth(),
213+
verticalAlignment = Alignment.CenterVertically,
214+
) {
215+
Image(
216+
modifier = Modifier
217+
.testTag(VERIFICATION_HEADER_IMAGE_TAG),
218+
painter = painterResource(R.drawable.stripe_link_logo),
219+
contentDescription = stringResource(com.stripe.android.R.string.stripe_link),
220+
)
221+
222+
Spacer(modifier = Modifier.weight(1f))
223+
}
224+
225+
Spacer(
226+
modifier = Modifier.size(24.dp)
227+
)
228+
Text(
229+
text = stringResource(R.string.stripe_verification_dialog_header),
230+
modifier = Modifier
231+
.testTag(VERIFICATION_TITLE_TAG),
232+
textAlign = TextAlign.Companion.Center,
233+
style = MaterialTheme.typography.h2,
234+
color = MaterialTheme.colors.onPrimary
235+
)
236+
} else {
237+
Text(
238+
text = stringResource(R.string.stripe_verification_dialog_header),
239+
modifier = Modifier
240+
.testTag(VERIFICATION_TITLE_TAG)
241+
.padding(vertical = 4.dp),
242+
textAlign = TextAlign.Companion.Center,
243+
style = MaterialTheme.typography.h2,
244+
color = MaterialTheme.colors.onPrimary
245+
)
246+
}
247+
}
248+
249+
@Composable
250+
private fun ChangeEmailRow(
251+
email: String,
252+
isProcessing: Boolean,
253+
onChangeEmailClick: () -> Unit,
254+
) {
255+
Row(
256+
horizontalArrangement = Arrangement.Center
257+
) {
258+
Text(
259+
text = email,
260+
modifier = Modifier.weight(weight = 1f, fill = false),
261+
color = MaterialTheme.colors.onSecondary,
262+
overflow = TextOverflow.Companion.Ellipsis,
263+
maxLines = 1,
264+
style = MaterialTheme.typography.body2
265+
)
266+
Text(
267+
text = stringResource(id = R.string.stripe_verification_change_email_new),
268+
modifier = Modifier
269+
.testTag(VERIFICATION_CHANGE_EMAIL_TAG)
270+
.padding(start = 4.dp)
271+
.clickable(
272+
enabled = !isProcessing,
273+
onClick = onChangeEmailClick
274+
),
275+
color = MaterialTheme.linkColors.textBrand,
276+
maxLines = 1,
277+
style = MaterialTheme.typography.body2
278+
)
279+
}
280+
}
281+
282+
@Composable
283+
private fun ResendCodeButton(
284+
isProcessing: Boolean,
285+
isSendingNewCode: Boolean,
286+
onClick: () -> Unit,
287+
) {
288+
Box(
289+
modifier = Modifier
290+
.testTag(VERIFICATION_RESEND_CODE_BUTTON_TAG)
291+
.clickable(
292+
enabled = !isProcessing && !isSendingNewCode,
293+
onClick = onClick,
294+
),
295+
contentAlignment = Alignment.Companion.Center
296+
) {
297+
val textAlpha = if (isProcessing) {
298+
ContentAlpha.disabled
299+
} else if (isSendingNewCode) {
300+
0f
301+
} else {
302+
ContentAlpha.high
303+
}
304+
305+
Text(
306+
text = stringResource(id = R.string.stripe_verification_resend),
307+
style = MaterialTheme.typography.button,
308+
color = MaterialTheme.linkColors.textBrand,
309+
modifier = Modifier
310+
.alpha(textAlpha),
311+
)
312+
313+
AnimatedVisibility(
314+
visible = isSendingNewCode
315+
) {
316+
CircularProgressIndicator(
317+
color = MaterialTheme.linkColors.textBrand,
318+
strokeWidth = 2.dp,
319+
modifier = Modifier
320+
.testTag(VERIFICATION_RESEND_LOADER_TAG)
321+
.size(18.dp)
322+
)
323+
}
324+
}
325+
}
326+
327+
internal const val VERIFICATION_TITLE_TAG = "verification_title"
328+
internal const val VERIFICATION_SUBTITLE_TAG = "verification_subtitle"
329+
internal const val VERIFICATION_OTP_TAG = "verification_otp_tag"
330+
internal const val VERIFICATION_CHANGE_EMAIL_TAG = "verification_change_email_tag"
331+
internal const val VERIFICATION_ERROR_TAG = "verification_error_tag"
332+
internal const val VERIFICATION_RESEND_LOADER_TAG = "verification_resend_loader_tag"
333+
internal const val VERIFICATION_RESEND_CODE_BUTTON_TAG = "verification_resend_code_button_tag"
334+
internal const val VERIFICATION_HEADER_IMAGE_TAG = "verification_header_image_tag"
335+
internal const val VERIFICATION_HEADER_BUTTON_TAG = "verification_header_button_tag"

0 commit comments

Comments
 (0)