diff --git a/dependencies.gradle b/dependencies.gradle index 55ded03d54c..d910f91ebe3 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -70,6 +70,7 @@ ext.versions = [ truth : '1.1.3', turbine : '1.0.0', uiAutomator : '2.2.0', + workManager : '2.9.0', zxing : '3.5.2', ] @@ -121,6 +122,7 @@ ext.libs = [ navigationUi : "androidx.navigation:navigation-ui-ktx:${versions.androidxNavigation}", preference : "androidx.preference:preference-ktx:${versions.androidxPreference}", recyclerView : "androidx.recyclerview:recyclerview:${versions.androidxRecyclerview}", + workManager : "androidx.work:work-runtime-ktx:${versions.workManager}", ], camera : [ core : "androidx.camera:camera-core:${versions.cameraX}", @@ -197,6 +199,7 @@ ext.testLibs = [ coreKtx : "androidx.test:core-ktx:${versions.androidTest}", truth : "androidx.test.ext:truth:${versions.androidTest}", uiAutomator: "androidx.test.uiautomator:uiautomator:${versions.uiAutomator}", + workManager: "androidx.work:work-testing:${versions.workManager}", ], espresso : [ accessibility : "androidx.test.espresso:espresso-accessibility:${versions.espresso}", diff --git a/docs/payments-core/com.stripe.android.view.i18n/-translator-manager/index.html b/docs/payments-core/com.stripe.android.view.i18n/-translator-manager/index.html index c1cca4743dd..7ce37faefec 100644 --- a/docs/payments-core/com.stripe.android.view.i18n/-translator-manager/index.html +++ b/docs/payments-core/com.stripe.android.view.i18n/-translator-manager/index.html @@ -62,7 +62,7 @@
A class that provides a ErrorMessageTranslator for translating server-provided error messages, as defined in Stripe API Errors Reference.
See com.stripe.android.view.PaymentMethodsActivity for example usage.
To use a custom ErrorMessageTranslator in your app, override Application.onCreate in your app's Application subclass and call setErrorMessageTranslator.
public class MyApp extends Application { +object TranslatorManagerA class that provides a ErrorMessageTranslator for translating server-provided error messages, as defined in Stripe API Errors Reference.
See com.stripe.android.view.PaymentMethodsActivity for example usage.
To use a custom ErrorMessageTranslator in your app, override Application.onCreate in your app's Application subclass and call setErrorMessageTranslator.
public class MyApp extends Application { public void onCreate() { super.onCreate(); TranslatorManager.setErrorMessageTranslator(new MyErrorMessageTranslator()); diff --git a/financial-connections-example/build.gradle b/financial-connections-example/build.gradle index d0c2db464b6..5e96cefca5b 100644 --- a/financial-connections-example/build.gradle +++ b/financial-connections-example/build.gradle @@ -55,6 +55,7 @@ dependencies { implementation libs.androidx.coreKtx implementation libs.androidx.lifecycle implementation libs.androidx.liveDataKtx + implementation libs.androidx.workManager implementation libs.compose.activity implementation libs.compose.material implementation libs.compose.liveData diff --git a/financial-connections-example/dependencies/dependencies.txt b/financial-connections-example/dependencies/dependencies.txt index cb2b9daba98..4cd13ef2c2d 100644 --- a/financial-connections-example/dependencies/dependencies.txt +++ b/financial-connections-example/dependencies/dependencies.txt @@ -57,6 +57,7 @@ | | | | | | +--- androidx.lifecycle:lifecycle-livedata-ktx:2.7.0 (c) | | | | | | +--- androidx.lifecycle:lifecycle-runtime:2.7.0 (c) | | | | | | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.7.0 (c) +| | | | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel:2.7.0 (c) | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 (c) @@ -82,6 +83,7 @@ | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 (c) | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0 (c) +| | | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | | \--- androidx.lifecycle:lifecycle-process:2.7.0 (c) | | | | +--- androidx.versionedparcelable:versionedparcelable:1.1.1 | | | | | +--- androidx.annotation:annotation:1.1.0 -> 1.7.1 (*) @@ -161,6 +163,7 @@ | | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 (c) | | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0 (c) +| | | | | | | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | | | | | | \--- androidx.lifecycle:lifecycle-process:2.7.0 (c) | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1 -> 2.7.0 | | | | | | | | | +--- androidx.annotation:annotation:1.0.0 -> 1.7.1 (*) @@ -177,6 +180,7 @@ | | | | | | | | | | +--- androidx.lifecycle:lifecycle-livedata-ktx:2.7.0 (c) | | | | | | | | | | +--- androidx.lifecycle:lifecycle-runtime:2.7.0 (c) | | | | | | | | | | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.7.0 (c) +| | | | | | | | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel:2.7.0 (c) | | | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 (c) @@ -202,6 +206,7 @@ | | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | | | | | | +--- androidx.lifecycle:lifecycle-common-java8:2.7.0 (c) | | | | | | | | | +--- androidx.lifecycle:lifecycle-common:2.7.0 (c) +| | | | | | | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | | | | | | \--- androidx.lifecycle:lifecycle-process:2.7.0 (c) | | | | | | | | +--- androidx.profileinstaller:profileinstaller:1.3.0 (*) | | | | | | | | +--- androidx.savedstate:savedstate:1.2.1 (*) @@ -226,6 +231,7 @@ | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | | | | | +--- androidx.lifecycle:lifecycle-common-java8:2.7.0 (c) | | | | | | | | +--- androidx.lifecycle:lifecycle-common:2.7.0 (c) +| | | | | | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | | | | | \--- androidx.lifecycle:lifecycle-process:2.7.0 (c) | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1 -> 2.7.0 | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel:2.7.0 (*) @@ -242,6 +248,7 @@ | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | | | | | +--- androidx.lifecycle:lifecycle-common-java8:2.7.0 (c) | | | | | | | | +--- androidx.lifecycle:lifecycle-common:2.7.0 (c) +| | | | | | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | | | | | \--- androidx.lifecycle:lifecycle-process:2.7.0 (c) | | | | | | | +--- androidx.savedstate:savedstate-ktx:1.2.1 | | | | | | | | +--- androidx.savedstate:savedstate:1.2.1 (*) @@ -336,6 +343,7 @@ | | | | | | | | | +--- androidx.lifecycle:lifecycle-livedata-ktx:2.7.0 (c) | | | | | | | | | +--- androidx.lifecycle:lifecycle-runtime:2.7.0 (c) | | | | | | | | | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.7.0 (c) +| | | | | | | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel:2.7.0 (c) | | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 (c) @@ -523,6 +531,7 @@ | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 (c) | | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0 (c) +| | | | | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | | | | \--- androidx.lifecycle:lifecycle-process:2.7.0 (c) | | | | | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 1.9.22 (*) | | | | | | +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1 -> 1.7.3 (*) @@ -537,6 +546,7 @@ | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 (c) | | | | | | +--- androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0 (c) +| | | | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | | | \--- androidx.lifecycle:lifecycle-process:2.7.0 (c) | | | | | \--- androidx.lifecycle:lifecycle-viewmodel:2.0.0 -> 2.7.0 (*) | | | | +--- androidx.profileinstaller:profileinstaller:1.3.0 (*) @@ -597,6 +607,7 @@ | | | | +--- androidx.lifecycle:lifecycle-livedata-ktx:2.7.0 (c) | | | | +--- androidx.lifecycle:lifecycle-runtime:2.7.0 (c) | | | | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.7.0 (c) +| | | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | | +--- androidx.lifecycle:lifecycle-viewmodel:2.7.0 (c) | | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 (c) @@ -613,6 +624,7 @@ | | | +--- androidx.lifecycle:lifecycle-livedata-ktx:2.7.0 (c) | | | +--- androidx.lifecycle:lifecycle-runtime:2.7.0 (c) | | | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | +--- androidx.lifecycle:lifecycle-viewmodel:2.7.0 (c) | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 (c) | | | +--- androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0 (c) @@ -863,6 +875,7 @@ | | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) | | | +--- androidx.lifecycle:lifecycle-common-java8:2.7.0 (c) | | | +--- androidx.lifecycle:lifecycle-common:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-service:2.7.0 (c) | | | \--- androidx.lifecycle:lifecycle-process:2.7.0 (c) | | +--- androidx.core:core-ktx:1.12.0 (*) | | +--- androidx.activity:activity-ktx:1.8.2 (*) @@ -881,6 +894,51 @@ +--- androidx.core:core-ktx:1.12.0 (*) +--- androidx.lifecycle:lifecycle-runtime-ktx:2.7.0 (*) +--- androidx.lifecycle:lifecycle-livedata-ktx:2.7.0 (*) ++--- androidx.work:work-runtime-ktx:2.9.0 +| +--- androidx.work:work-runtime:2.9.0 +| | +--- androidx.annotation:annotation-experimental:1.0.0 -> 1.3.0 (*) +| | +--- androidx.core:core:1.9.0 -> 1.12.0 (*) +| | +--- androidx.lifecycle:lifecycle-livedata:2.5.1 -> 2.7.0 (*) +| | +--- androidx.lifecycle:lifecycle-service:2.5.1 -> 2.7.0 +| | | +--- androidx.lifecycle:lifecycle-runtime:2.7.0 (*) +| | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 1.9.22 (*) +| | | +--- androidx.lifecycle:lifecycle-common:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-common-java8:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-livedata:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-livedata-core:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-livedata-core-ktx:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-livedata-ktx:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-runtime:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-viewmodel:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 (c) +| | | +--- androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0 (c) +| | | \--- androidx.lifecycle:lifecycle-process:2.7.0 (c) +| | +--- androidx.room:room-ktx:2.5.0 +| | | +--- androidx.room:room-common:2.5.0 +| | | | +--- androidx.annotation:annotation:1.3.0 -> 1.7.1 (*) +| | | | \--- org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.20 -> 1.9.10 (*) +| | | +--- androidx.room:room-runtime:2.5.0 +| | | | +--- androidx.annotation:annotation-experimental:1.1.0 -> 1.3.0 (*) +| | | | +--- androidx.arch.core:core-runtime:2.0.1 -> 2.2.0 (*) +| | | | +--- androidx.room:room-common:2.5.0 (*) +| | | | +--- androidx.sqlite:sqlite:2.3.0 +| | | | | +--- androidx.annotation:annotation:1.0.0 -> 1.7.1 (*) +| | | | | \--- org.jetbrains.kotlin:kotlin-stdlib:1.7.20 -> 1.9.22 (*) +| | | | \--- androidx.sqlite:sqlite-framework:2.3.0 +| | | | +--- androidx.annotation:annotation:1.2.0 -> 1.7.1 (*) +| | | | +--- androidx.sqlite:sqlite:2.3.0 (*) +| | | | \--- org.jetbrains.kotlin:kotlin-stdlib:1.7.20 -> 1.9.22 (*) +| | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.7.20 -> 1.9.22 (*) +| | | \--- org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4 -> 1.7.3 (*) +| | +--- androidx.sqlite:sqlite-framework:2.3.0 (*) +| | +--- androidx.startup:startup-runtime:1.1.1 (*) +| | +--- com.google.guava:listenablefuture:1.0 +| | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 1.9.22 (*) +| | +--- org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1 -> 1.7.3 (*) +| | \--- androidx.work:work-runtime-ktx:2.9.0 (c) +| \--- androidx.work:work-runtime:2.9.0 (c) +--- androidx.activity:activity-compose:1.8.2 (*) +--- androidx.compose.material:material:1.5.4 (*) +--- androidx.compose.runtime:runtime-livedata:1.5.4 diff --git a/financial-connections-example/src/main/AndroidManifest.xml b/financial-connections-example/src/main/AndroidManifest.xml index d3a7c027fb8..cd1365a985a 100644 --- a/financial-connections-example/src/main/AndroidManifest.xml +++ b/financial-connections-example/src/main/AndroidManifest.xml @@ -34,6 +34,8 @@ android:theme="@style/AppTheme.NoActionBar" />diff --git a/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/FinancialConnectionsPlaygroundActivity.kt b/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/FinancialConnectionsPlaygroundActivity.kt index f707ba76cfe..e16a78157dc 100644 --- a/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/FinancialConnectionsPlaygroundActivity.kt +++ b/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/FinancialConnectionsPlaygroundActivity.kt @@ -5,7 +5,6 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -144,10 +143,10 @@ class FinancialConnectionsPlaygroundActivity : AppCompatActivity() { }, content = { PlaygroundContent( - padding = it, state = state, onSettingsChanged = onSettingsChanged, - onButtonClick = onButtonClick + onButtonClick = onButtonClick, + modifier = Modifier.padding(it), ) } ) @@ -157,56 +156,71 @@ class FinancialConnectionsPlaygroundActivity : AppCompatActivity() { @Composable @Suppress("LongMethod") private fun PlaygroundContent( - padding: PaddingValues, state: FinancialConnectionsPlaygroundState, onSettingsChanged: (PlaygroundSettings) -> Unit, - onButtonClick: () -> Unit + onButtonClick: () -> Unit, + modifier: Modifier = Modifier, ) { - Column( - modifier = Modifier - .padding(padding) - .padding(16.dp) + LazyColumn( + contentPadding = PaddingValues(16.dp), + modifier = modifier, ) { - SettingsUi( - playgroundSettings = state.settings, - onSettingsChanged = onSettingsChanged - ) + item { + SettingsUi( + playgroundSettings = state.settings, + onSettingsChanged = onSettingsChanged + ) + } + if (state.loading) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), + item { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + ) + } + } + + item { + Divider(Modifier.padding(vertical = 8.dp)) + } + + item { + Text( + text = "backend: ${state.backendUrl}", + color = MaterialTheme.colors.secondaryVariant ) } - Divider(Modifier.padding(vertical = 8.dp)) - Text( - text = "backend: ${state.backendUrl}", - color = MaterialTheme.colors.secondaryVariant - ) - Text( - text = "env: ${BuildConfig.TEST_ENVIRONMENT}", - color = MaterialTheme.colors.secondaryVariant - ) - Button( - modifier = Modifier - .semantics { testTagsAsResourceId = true } - .testTag("connect_accounts"), - onClick = onButtonClick, - ) { - Text("Connect Accounts") + + item { + Text( + text = "env: ${BuildConfig.TEST_ENVIRONMENT}", + color = MaterialTheme.colors.secondaryVariant + ) + } + + item { + Button( + modifier = Modifier + .semantics { testTagsAsResourceId = true } + .testTag("connect_accounts"), + onClick = onButtonClick, + ) { + Text("Connect Accounts") + } } - LazyColumn { - items(state.status) { item -> - Row(Modifier.padding(4.dp), verticalAlignment = Alignment.Top) { - val primary = MaterialTheme.colors.primary - Canvas( - modifier = Modifier - .padding(end = 8.dp, top = 6.dp) - .size(6.dp) - ) { - drawCircle(primary) - } - SelectionContainer { - Text(text = item, fontSize = 12.sp) - } + + items(state.status) { item -> + Row(Modifier.padding(4.dp), verticalAlignment = Alignment.Top) { + val primary = MaterialTheme.colors.primary + Canvas( + modifier = Modifier + .padding(end = 8.dp, top = 6.dp) + .size(6.dp) + ) { + drawCircle(primary) + } + SelectionContainer { + Text(text = item, fontSize = 12.sp) } } } diff --git a/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/settings/EmailSetting.kt b/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/settings/EmailSetting.kt index 871c2e107b1..682dc0bb343 100644 --- a/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/settings/EmailSetting.kt +++ b/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/settings/EmailSetting.kt @@ -4,8 +4,9 @@ import com.stripe.android.financialconnections.example.data.model.LinkAccountSes import com.stripe.android.financialconnections.example.data.model.PaymentIntentBody internal data class EmailSetting( - override val selectedOption: String = "" -) : SingleChoiceSetting ( + override val selectedOption: String = "", + override val key: String = "email", +) : Saveable , SingleChoiceSetting ( displayName = "Customer email", options = emptyList(), selectedOption = selectedOption @@ -24,4 +25,7 @@ internal data class EmailSetting( ): List > { return replace(currentSettings, this.copy(selectedOption = value)) } + + override fun convertToString(value: String): String = value + override fun convertToValue(value: String): String = value } diff --git a/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/settings/SettingsUi.kt b/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/settings/SettingsUi.kt index 21318c76e40..43eeacd6d95 100644 --- a/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/settings/SettingsUi.kt +++ b/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/settings/SettingsUi.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Checkbox import androidx.compose.material.DropdownMenuItem import androidx.compose.material.ExperimentalMaterialApi @@ -30,6 +31,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp @Composable @@ -161,6 +163,9 @@ private fun TextSetting( placeholder = { Text(text = name) }, label = { Text(text = name) }, value = value, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), onValueChange = { newValue: String -> onOptionChanged(newValue) }, diff --git a/financial-connections/api/financial-connections.api b/financial-connections/api/financial-connections.api index 0440e3997d8..e31e5f71620 100644 --- a/financial-connections/api/financial-connections.api +++ b/financial-connections/api/financial-connections.api @@ -306,9 +306,11 @@ public final class com/stripe/android/financialconnections/features/accountpicke public static final field INSTANCE Lcom/stripe/android/financialconnections/features/accountpicker/ComposableSingletons$AccountPickerScreenKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; public static field lambda-2 Lkotlin/jvm/functions/Function3; + public static field lambda-3 Lkotlin/jvm/functions/Function4; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function4; } public final class com/stripe/android/financialconnections/features/attachpayment/ComposableSingletons$AttachPaymentScreenKt { @@ -318,151 +320,158 @@ public final class com/stripe/android/financialconnections/features/attachpaymen public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; } -public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$AccessibleDataCalloutKt { - public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$AccessibleDataCalloutKt; +public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$AccountItemKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$AccountItemKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function3; + public static field lambda-3 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function2; +} + +public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$ErrorContentKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$ErrorContentKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; public static field lambda-2 Lkotlin/jvm/functions/Function2; public static field lambda-3 Lkotlin/jvm/functions/Function2; - public static field lambda-4 Lkotlin/jvm/functions/Function2; + public static field lambda-4 Lkotlin/jvm/functions/Function3; public static field lambda-5 Lkotlin/jvm/functions/Function2; - public static field lambda-6 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-5$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-6$financial_connections_release ()Lkotlin/jvm/functions/Function2; } -public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$CloseDialogKt { - public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$CloseDialogKt; - public static field lambda-1 Lkotlin/jvm/functions/Function3; +public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$LoadingContentKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$LoadingContentKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; public static field lambda-2 Lkotlin/jvm/functions/Function3; public static field lambda-3 Lkotlin/jvm/functions/Function2; public static field lambda-4 Lkotlin/jvm/functions/Function2; + public static field lambda-5 Lkotlin/jvm/functions/Function3; + public static field lambda-6 Lkotlin/jvm/functions/Function3; + public static field lambda-7 Lkotlin/jvm/functions/Function3; + public static field lambda-8 Lkotlin/jvm/functions/Function2; public fun ()V - public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-5$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-6$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-7$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-8$financial_connections_release ()Lkotlin/jvm/functions/Function2; } -public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$ErrorContentKt { - public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$ErrorContentKt; +public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$MerchantDataAccessTextKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$MerchantDataAccessTextKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; public static field lambda-2 Lkotlin/jvm/functions/Function3; public static field lambda-3 Lkotlin/jvm/functions/Function2; - public static field lambda-4 Lkotlin/jvm/functions/Function2; - public static field lambda-5 Lkotlin/jvm/functions/Function3; - public static field lambda-6 Lkotlin/jvm/functions/Function2; - public static field lambda-7 Lkotlin/jvm/functions/Function2; - public static field lambda-8 Lkotlin/jvm/functions/Function3; - public static field lambda-9 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-5$financial_connections_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-6$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-7$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-8$financial_connections_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-9$financial_connections_release ()Lkotlin/jvm/functions/Function2; } -public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$ModalBottomSheetContentKt { - public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$ModalBottomSheetContentKt; +public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$SharedPartnerAuthKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$SharedPartnerAuthKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function3; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; } -public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$SharedPartnerAuthKt { - public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$SharedPartnerAuthKt; +public final class com/stripe/android/financialconnections/features/consent/ui/ComposableSingletons$ConsentLogoHeaderKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/consent/ui/ComposableSingletons$ConsentLogoHeaderKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; } -public final class com/stripe/android/financialconnections/features/consent/ComposableSingletons$ConsentScreenKt { - public static final field INSTANCE Lcom/stripe/android/financialconnections/features/consent/ComposableSingletons$ConsentScreenKt; +public final class com/stripe/android/financialconnections/features/error/ComposableSingletons$ErrorScreenKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/error/ComposableSingletons$ErrorScreenKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; +} + +public final class com/stripe/android/financialconnections/features/exit/ComposableSingletons$ExitModalKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/exit/ComposableSingletons$ExitModalKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function3; + public static field lambda-3 Lkotlin/jvm/functions/Function2; + public static field lambda-4 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function2; } public final class com/stripe/android/financialconnections/features/institutionpicker/ComposableSingletons$InstitutionPickerScreenKt { public static final field INSTANCE Lcom/stripe/android/financialconnections/features/institutionpicker/ComposableSingletons$InstitutionPickerScreenKt; - public static field lambda-1 Lkotlin/jvm/functions/Function2; - public static field lambda-2 Lkotlin/jvm/functions/Function2; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function3; public static field lambda-3 Lkotlin/jvm/functions/Function3; - public static field lambda-4 Lkotlin/jvm/functions/Function3; - public static field lambda-5 Lkotlin/jvm/functions/Function3; - public static field lambda-6 Lkotlin/jvm/functions/Function3; + public static field lambda-4 Lkotlin/jvm/functions/Function2; + public static field lambda-5 Lkotlin/jvm/functions/Function2; public fun ()V - public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-5$financial_connections_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-6$financial_connections_release ()Lkotlin/jvm/functions/Function3; -} - -public final class com/stripe/android/financialconnections/features/linkstepupverification/ComposableSingletons$LinkStepUpVerificationScreenKt { - public static final field INSTANCE Lcom/stripe/android/financialconnections/features/linkstepupverification/ComposableSingletons$LinkStepUpVerificationScreenKt; - public static field lambda-1 Lkotlin/jvm/functions/Function2; - public static field lambda-2 Lkotlin/jvm/functions/Function2; - public fun ()V - public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-5$financial_connections_release ()Lkotlin/jvm/functions/Function2; } -public final class com/stripe/android/financialconnections/features/manualentry/ComposableSingletons$ManualEntryScreenKt { - public static final field INSTANCE Lcom/stripe/android/financialconnections/features/manualentry/ComposableSingletons$ManualEntryScreenKt; +public final class com/stripe/android/financialconnections/features/linkaccountpicker/ComposableSingletons$LinkAccountPickerScreenKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/linkaccountpicker/ComposableSingletons$LinkAccountPickerScreenKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function3; + public static field lambda-3 Lkotlin/jvm/functions/Function4; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function4; } -public final class com/stripe/android/financialconnections/features/manualentrysuccess/ComposableSingletons$ManualEntrySuccessScreenKt { - public static final field INSTANCE Lcom/stripe/android/financialconnections/features/manualentrysuccess/ComposableSingletons$ManualEntrySuccessScreenKt; +public final class com/stripe/android/financialconnections/features/manualentry/ComposableSingletons$ManualEntryScreenKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/manualentry/ComposableSingletons$ManualEntryScreenKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; - public static field lambda-2 Lkotlin/jvm/functions/Function2; - public static field lambda-3 Lkotlin/jvm/functions/Function2; - public static field lambda-4 Lkotlin/jvm/functions/Function2; - public static field lambda-5 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-5$financial_connections_release ()Lkotlin/jvm/functions/Function2; } public final class com/stripe/android/financialconnections/features/networkinglinkloginwarmup/ComposableSingletons$NetworkingLinkLoginWarmupScreenKt { public static final field INSTANCE Lcom/stripe/android/financialconnections/features/networkinglinkloginwarmup/ComposableSingletons$NetworkingLinkLoginWarmupScreenKt; - public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function3; + public static field lambda-3 Lkotlin/jvm/functions/Function3; public fun ()V - public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function3; } public final class com/stripe/android/financialconnections/features/networkinglinkverification/ComposableSingletons$NetworkingLinkVerificationScreenKt { public static final field INSTANCE Lcom/stripe/android/financialconnections/features/networkinglinkverification/ComposableSingletons$NetworkingLinkVerificationScreenKt; - public static field lambda-1 Lkotlin/jvm/functions/Function2; - public static field lambda-2 Lkotlin/jvm/functions/Function2; + public static field lambda-1 Lkotlin/jvm/functions/Function3; public fun ()V - public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; } public final class com/stripe/android/financialconnections/features/networkingsavetolinkverification/ComposableSingletons$NetworkingSaveToLinkVerificationScreenKt { public static final field INSTANCE Lcom/stripe/android/financialconnections/features/networkingsavetolinkverification/ComposableSingletons$NetworkingSaveToLinkVerificationScreenKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; - public static field lambda-2 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function3; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; } public final class com/stripe/android/financialconnections/features/reset/ComposableSingletons$ResetScreenKt { @@ -472,15 +481,11 @@ public final class com/stripe/android/financialconnections/features/reset/Compos public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; } -public final class com/stripe/android/financialconnections/features/success/ComposableSingletons$SuccessScreenKt { - public static final field INSTANCE Lcom/stripe/android/financialconnections/features/success/ComposableSingletons$SuccessScreenKt; +public final class com/stripe/android/financialconnections/features/success/ComposableSingletons$SuccessContentKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/features/success/ComposableSingletons$SuccessContentKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; - public static field lambda-2 Lkotlin/jvm/functions/Function2; - public static field lambda-3 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function2; } public final class com/stripe/android/financialconnections/launcher/FinancialConnectionsSheetActivityArgs$Companion { @@ -839,6 +844,14 @@ public final class com/stripe/android/financialconnections/model/CashBalance$Cre public synthetic fun newArray (I)[Ljava/lang/Object; } +public final class com/stripe/android/financialconnections/model/ConnectedAccessNotice$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/financialconnections/model/ConnectedAccessNotice; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lcom/stripe/android/financialconnections/model/ConnectedAccessNotice; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + public final class com/stripe/android/financialconnections/model/ConsentPane$Creator : android/os/Parcelable$Creator { public fun ()V public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/financialconnections/model/ConsentPane; @@ -1576,6 +1589,14 @@ public final class com/stripe/android/financialconnections/model/ReturningNetwor public synthetic fun newArray (I)[Ljava/lang/Object; } +public final class com/stripe/android/financialconnections/model/ServerLink$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/financialconnections/model/ServerLink; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lcom/stripe/android/financialconnections/model/ServerLink; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + public final class com/stripe/android/financialconnections/model/SynchronizeSessionResponse$Creator : android/os/Parcelable$Creator { public fun ()V public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/financialconnections/model/SynchronizeSessionResponse; @@ -1610,6 +1631,9 @@ public final class com/stripe/android/financialconnections/navigation/Composable public static field lambda-14 Lkotlin/jvm/functions/Function3; public static field lambda-15 Lkotlin/jvm/functions/Function3; public static field lambda-16 Lkotlin/jvm/functions/Function3; + public static field lambda-17 Lkotlin/jvm/functions/Function3; + public static field lambda-18 Lkotlin/jvm/functions/Function3; + public static field lambda-19 Lkotlin/jvm/functions/Function3; public static field lambda-2 Lkotlin/jvm/functions/Function3; public static field lambda-3 Lkotlin/jvm/functions/Function3; public static field lambda-4 Lkotlin/jvm/functions/Function3; @@ -1627,6 +1651,9 @@ public final class com/stripe/android/financialconnections/navigation/Composable public final fun getLambda-14$financial_connections_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-15$financial_connections_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-16$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-17$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-18$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-19$financial_connections_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function3; @@ -1637,6 +1664,13 @@ public final class com/stripe/android/financialconnections/navigation/Composable public final fun getLambda-9$financial_connections_release ()Lkotlin/jvm/functions/Function3; } +public final class com/stripe/android/financialconnections/navigation/bottomsheet/ComposableSingletons$BottomSheetNavigationKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/navigation/bottomsheet/ComposableSingletons$BottomSheetNavigationKt; + public static field lambda-1 Lkotlin/jvm/functions/Function4; + public fun ()V + public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function4; +} + public final class com/stripe/android/financialconnections/presentation/WebAuthFlowState$Canceled$Creator : android/os/Parcelable$Creator { public fun ()V public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/financialconnections/presentation/WebAuthFlowState$Canceled; @@ -1694,11 +1728,24 @@ public final class com/stripe/android/financialconnections/ui/components/Composa public final fun getLambda-6$financial_connections_release ()Lkotlin/jvm/functions/Function2; } +public final class com/stripe/android/financialconnections/ui/components/ComposableSingletons$TestModeBannerKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/ui/components/ComposableSingletons$TestModeBannerKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function2; +} + public final class com/stripe/android/financialconnections/ui/components/ComposableSingletons$TextFieldKt { public static final field INSTANCE Lcom/stripe/android/financialconnections/ui/components/ComposableSingletons$TextFieldKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function3; + public static field lambda-3 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function2; } public final class com/stripe/android/financialconnections/ui/components/ComposableSingletons$TopAppBarKt { @@ -1707,15 +1754,33 @@ public final class com/stripe/android/financialconnections/ui/components/Composa public static field lambda-2 Lkotlin/jvm/functions/Function2; public static field lambda-3 Lkotlin/jvm/functions/Function2; public static field lambda-4 Lkotlin/jvm/functions/Function2; - public static field lambda-5 Lkotlin/jvm/functions/Function2; - public static field lambda-6 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function2; +} + +public final class com/stripe/android/financialconnections/ui/theme/ComposableSingletons$ColorKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/ui/theme/ComposableSingletons$ColorKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; +} + +public final class com/stripe/android/financialconnections/ui/theme/ComposableSingletons$LayoutKt { + public static final field INSTANCE Lcom/stripe/android/financialconnections/ui/theme/ComposableSingletons$LayoutKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function3; + public static field lambda-3 Lkotlin/jvm/functions/Function3; + public static field lambda-4 Lkotlin/jvm/functions/Function2; + public static field lambda-5 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-3$financial_connections_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-4$financial_connections_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-5$financial_connections_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-6$financial_connections_release ()Lkotlin/jvm/functions/Function2; } public final class com/stripe/android/financialconnections/ui/theme/ComposableSingletons$TypeKt { diff --git a/financial-connections/build.gradle b/financial-connections/build.gradle index 0ba7b8db0ba..5b695b7e4af 100644 --- a/financial-connections/build.gradle +++ b/financial-connections/build.gradle @@ -56,6 +56,7 @@ dependencies { testImplementation testLibs.androidx.core testImplementation testLibs.androidx.fragment testImplementation testLibs.androidx.junit + testImplementation testLibs.androidx.lifecycle testImplementation testLibs.json testImplementation testLibs.junit testImplementation testLibs.kotlin.annotations diff --git a/financial-connections/detekt-baseline.xml b/financial-connections/detekt-baseline.xml index c373eea4339..43504de42f7 100644 --- a/financial-connections/detekt-baseline.xml +++ b/financial-connections/detekt-baseline.xml @@ -1,5 +1,47 @@ diff --git a/financial-connections/res/drawable/stripe_check_account.png b/financial-connections/res/drawable/stripe_check_account.png deleted file mode 100644 index 9de99abb457..00000000000 Binary files a/financial-connections/res/drawable/stripe_check_account.png and /dev/null differ diff --git a/financial-connections/res/drawable/stripe_check_base.png b/financial-connections/res/drawable/stripe_check_base.png deleted file mode 100644 index 776e62487ca..00000000000 Binary files a/financial-connections/res/drawable/stripe_check_base.png and /dev/null differ diff --git a/financial-connections/res/drawable/stripe_check_routing.png b/financial-connections/res/drawable/stripe_check_routing.png deleted file mode 100644 index 1aa9a343945..00000000000 Binary files a/financial-connections/res/drawable/stripe_check_routing.png and /dev/null differ diff --git a/financial-connections/res/drawable/stripe_consent_logo_ellipsis.xml b/financial-connections/res/drawable/stripe_consent_logo_ellipsis.xml deleted file mode 100644 index 16cca89cfbd..00000000000 --- a/financial-connections/res/drawable/stripe_consent_logo_ellipsis.xml +++ /dev/null @@ -1,15 +0,0 @@ - - + + ConstructorParameterNaming:FinancialConnectionsAuthorizationSession.kt$FinancialConnectionsAuthorizationSession$@SerialName(value = "is_oauth") private val _isOAuth: Boolean? = false +ConstructorParameterNaming:PartnerAccountsList.kt$PartnerAccount$@SerialName(value = "allow_selection") private val _allowSelection: Boolean? = null +LongMethod:AccountItem.kt$@Composable @Preview internal fun AccountItemPreview() +LongMethod:InstitutionPickerScreen.kt$@Composable private fun SearchRow( modifier: Modifier = Modifier, focusRequester: FocusRequester, query: TextFieldValue, onQueryChanged: (TextFieldValue) -> Unit, ) +LongMethod:InstitutionPickerScreen.kt$private fun LazyListScope.searchResults( isInputEmpty: Boolean, payload: Payload, selectedInstitutionId: String?, onInstitutionSelected: (FinancialConnectionsInstitution, Boolean) -> Unit, institutions: Async<InstitutionResponse>, onManualEntryClick: () -> Unit, onSearchMoreClick: () -> Unit ) +LongMethod:LinkAccountPickerPreviewParameterProvider.kt$LinkAccountPickerPreviewParameterProvider$private fun partnerAccountList() +LongMethod:ManualEntryScreen.kt$@Composable private fun ManualEntryLoaded( scrollState: ScrollState, payload: Payload, linkPaymentAccountStatus: Async<LinkAccountSessionPaymentAccount>, routing: Pair<String?, Int?>, onRoutingEntered: (String) -> Unit, account: Pair<String?, Int?>, onAccountEntered: (String) -> Unit, accountConfirm: Pair<String?, Int?>, onAccountConfirmEntered: (String) -> Unit, isValidForm: Boolean, onSubmit: () -> Unit ) +MagicNumber:AccountPickerScreen.kt$3 +MagicNumber:ConsentLogoHeader.kt$3 +MagicNumber:ConsentLogoHeader.kt$4 +MagicNumber:ConsentLogoHeader.kt$5 +MagicNumber:ConsumerSessionExtensions.kt$10 +MagicNumber:ConsumerSessionExtensions.kt$5 +MagicNumber:ConsumerSessionExtensions.kt$7 +MagicNumber:InstitutionPickerScreen.kt$0.5f +MagicNumber:InstitutionPickerScreen.kt$0.75f +MagicNumber:InstitutionPickerScreen.kt$10 +MagicNumber:Layout.kt$4 +MagicNumber:Layout.kt$50 +MagicNumber:LinkAccountPickerScreen.kt$3 +MagicNumber:ManualEntryInputValidator.kt$ManualEntryInputValidator$10 +MagicNumber:ManualEntryInputValidator.kt$ManualEntryInputValidator$3 +MagicNumber:ManualEntryInputValidator.kt$ManualEntryInputValidator$7 +MagicNumber:ManualEntryViewModel.kt$ManualEntryViewModel$4 +MagicNumber:SharedPartnerAuth.kt$.50f +MatchingDeclarationName:ServerDrivenUi.kt$BulletUI +MatchingDeclarationName:Type.kt$FinancialConnectionsTypography +MaxLineLength:FinancialConnectionsUrls.kt$FinancialConnectionsUrls.DataPolicy$const val merchant = "https://support.stripe.com/user/questions/what-data-does-stripe-access-from-my-linked-financial-account" +MaxLineLength:FinancialConnectionsUrls.kt$FinancialConnectionsUrls.Disconnect$const val link = "https://support.link.co/questions/connecting-your-bank-account#how-do-i-disconnect-my-connected-bank-account" +MaxLineLength:FinancialConnectionsUrls.kt$FinancialConnectionsUrls.PartnerNotice$const val merchant = "https://support.stripe.com/user/questions/what-is-the-relationship-between-stripe-and-stripes-service-providers" +MaximumLineLength:com.stripe.android.financialconnections.presentation.FinancialConnectionsUrls.kt:41 +MaximumLineLength:com.stripe.android.financialconnections.presentation.FinancialConnectionsUrls.kt:46 +MaximumLineLength:com.stripe.android.financialconnections.presentation.FinancialConnectionsUrls.kt:9 +NestedBlockDepth:InstitutionPickerScreen.kt$private fun LazyListScope.searchResults( isInputEmpty: Boolean, payload: Payload, selectedInstitutionId: String?, onInstitutionSelected: (FinancialConnectionsInstitution, Boolean) -> Unit, institutions: Async<InstitutionResponse>, onManualEntryClick: () -> Unit, onSearchMoreClick: () -> Unit ) +SwallowedException:PollAttachPaymentAccount.kt$PollAttachPaymentAccount$e: StripeException +SwallowedException:PollAuthorizationSessionAccounts.kt$PollAuthorizationSessionAccounts$e: StripeException +SwallowedException:PostAuthorizationSession.kt$PostAuthorizationSession$e: StripeException +TooManyFunctions:FinancialConnectionsManifestRepository.kt$FinancialConnectionsManifestRepository +TooManyFunctions:FinancialConnectionsManifestRepository.kt$FinancialConnectionsManifestRepositoryImpl : FinancialConnectionsManifestRepository +TooManyFunctions:FinancialConnectionsSheetNativeViewModel.kt$FinancialConnectionsSheetNativeViewModel : MavericksViewModel +TooManyFunctions:FinancialConnectionsSheetSharedModule.kt$FinancialConnectionsSheetSharedModule$Companion +- diff --git a/financial-connections/res/drawable/stripe_ic_add.xml b/financial-connections/res/drawable/stripe_ic_add.xml new file mode 100644 index 00000000000..14022ff5375 --- /dev/null +++ b/financial-connections/res/drawable/stripe_ic_add.xml @@ -0,0 +1,10 @@ +- - - + diff --git a/financial-connections/res/drawable/stripe_ic_arrow_right_circle.xml b/financial-connections/res/drawable/stripe_ic_arrow_right_circle.xml deleted file mode 100644 index 4da21a9f0a5..00000000000 --- a/financial-connections/res/drawable/stripe_ic_arrow_right_circle.xml +++ /dev/null @@ -1,10 +0,0 @@ -+ - diff --git a/financial-connections/res/drawable/stripe_ic_brandicon_institution_circle.xml b/financial-connections/res/drawable/stripe_ic_brandicon_institution_circle.xml deleted file mode 100644 index 40e82295f12..00000000000 --- a/financial-connections/res/drawable/stripe_ic_brandicon_institution_circle.xml +++ /dev/null @@ -1,13 +0,0 @@ -- - diff --git a/financial-connections/res/drawable/stripe_ic_check.xml b/financial-connections/res/drawable/stripe_ic_check.xml deleted file mode 100644 index cb13d09c321..00000000000 --- a/financial-connections/res/drawable/stripe_ic_check.xml +++ /dev/null @@ -1,10 +0,0 @@ -- - - diff --git a/financial-connections/res/drawable/stripe_ic_check_circle_emtpy.xml b/financial-connections/res/drawable/stripe_ic_check_circle_emtpy.xml deleted file mode 100644 index 65e406a9f5a..00000000000 --- a/financial-connections/res/drawable/stripe_ic_check_circle_emtpy.xml +++ /dev/null @@ -1,15 +0,0 @@ -- - diff --git a/financial-connections/res/drawable/stripe_ic_checkbox_no.xml b/financial-connections/res/drawable/stripe_ic_checkbox_no.xml deleted file mode 100644 index 2e9564b67ea..00000000000 --- a/financial-connections/res/drawable/stripe_ic_checkbox_no.xml +++ /dev/null @@ -1,11 +0,0 @@ -- - - diff --git a/financial-connections/res/drawable/stripe_ic_checkbox_yes.xml b/financial-connections/res/drawable/stripe_ic_checkbox_yes.xml deleted file mode 100644 index 1cc0e12726d..00000000000 --- a/financial-connections/res/drawable/stripe_ic_checkbox_yes.xml +++ /dev/null @@ -1,16 +0,0 @@ -- - diff --git a/financial-connections/res/drawable/stripe_ic_info.xml b/financial-connections/res/drawable/stripe_ic_info.xml new file mode 100644 index 00000000000..6468e0e09f2 --- /dev/null +++ b/financial-connections/res/drawable/stripe_ic_info.xml @@ -0,0 +1,10 @@ +- - + diff --git a/financial-connections/res/drawable/stripe_ic_panel_arrow_right.xml b/financial-connections/res/drawable/stripe_ic_panel_arrow_right.xml new file mode 100644 index 00000000000..21a5bc0a5f7 --- /dev/null +++ b/financial-connections/res/drawable/stripe_ic_panel_arrow_right.xml @@ -0,0 +1,10 @@ ++ + diff --git a/financial-connections/res/drawable/stripe_ic_person.xml b/financial-connections/res/drawable/stripe_ic_person.xml new file mode 100644 index 00000000000..8c1c4a3e41e --- /dev/null +++ b/financial-connections/res/drawable/stripe_ic_person.xml @@ -0,0 +1,12 @@ ++ + diff --git a/financial-connections/res/drawable/stripe_ic_radio_no.xml b/financial-connections/res/drawable/stripe_ic_radio_no.xml deleted file mode 100644 index 351f582d752..00000000000 --- a/financial-connections/res/drawable/stripe_ic_radio_no.xml +++ /dev/null @@ -1,11 +0,0 @@ -+ + - diff --git a/financial-connections/res/drawable/stripe_ic_radio_yes.xml b/financial-connections/res/drawable/stripe_ic_radio_yes.xml deleted file mode 100644 index 6c65f44fd0b..00000000000 --- a/financial-connections/res/drawable/stripe_ic_radio_yes.xml +++ /dev/null @@ -1,14 +0,0 @@ -- - diff --git a/financial-connections/res/drawable/stripe_ic_search.xml b/financial-connections/res/drawable/stripe_ic_search.xml new file mode 100644 index 00000000000..9f9724f6ad6 --- /dev/null +++ b/financial-connections/res/drawable/stripe_ic_search.xml @@ -0,0 +1,10 @@ +- - + diff --git a/financial-connections/res/drawable/stripe_prepane_phone_bg.xml b/financial-connections/res/drawable/stripe_prepane_phone_bg.xml deleted file mode 100644 index cc00d54c918..00000000000 --- a/financial-connections/res/drawable/stripe_prepane_phone_bg.xml +++ /dev/null @@ -1,41 +0,0 @@ -+ - diff --git a/financial-connections/res/values/strings.xml b/financial-connections/res/values/strings.xml index 506a5f61d09..131b927d008 100644 --- a/financial-connections/res/values/strings.xml +++ b/financial-connections/res/values/strings.xml @@ -6,14 +6,13 @@- - - - - - - - - OK Link with %1$s Continue -Select your bank -Double check your spelling and search terms -No results +Select bank Don’t see your bank? +Search for more banks Enter your account and routing numbers -Search is currently unavailable -Please try again later -Please try again later or +enter your bank details manually. No results +Try searching another bank +Try searching another bank or manually\u00a0enter\u00a0details .Establishing connection Please wait while we connect to your bank. No results for "%1$s" @@ -36,23 +35,35 @@Select accounts Confirm - -- Link account
-- Link accounts
+- Connect account
+- Connect accounts
Success! Search Choose one -- Data accessible to Stripe: %2$s.Learn more - Data accessible to %1$s: %2$s through Stripe.Learn more - Data accessible to this business: %2$s through Stripe.Learn more - Data accessible to %1$s: %2$s through Link.Learn more + Data accessible to this business: %2$s through Link.Learn more Stripe can access %2$s. +Learn more %1$s can access %2$s. +Learn more This business can access %2$s. Learn more account details balances transactions account ownership details +Success +You can expect micro–deposits to account ••••%1$s in 1–2 days and an email with further instructions. +You can expect micro–deposits to your account in 1–2 days and an email with further instructions. ++ +- Your account was connected
+- Your accounts were connected
++ +- Your account was connected and saved with Link
+- Your accounts were connected and saved with Link
++ - Your account was connected
+but couldn\'t be saved with Link - Your accounts were connected
+but couldn\'t be saved with Link Done -You can +disconnect your account any time.Back to %1$s Stripe works with partners like %1$s to reliably offer access to thousands of financial institutions. Learn more No payment accounts available @@ -61,27 +72,19 @@ There was a problem accessing your %1$s account Please select another bank or try again -Enter bank account details +Enter bank details Routing number Account number Confirm account number -Continue +Submit Please enter 9 digits for your routing number. Invalid routing number. Routing number required. Account number required. Invalid bank account number: must be at most 17 digits long. Your account numbers don\'t match. -Your bank information will be verified with micro-deposits to your account. -Please enter a checking account. -Micro-deposits initiated -Micro-deposit initiated -Expect two small deposits to the account ending in ••••%1$s in 1–2 business days and an email with additional instructions to verify your bank account. -Expect two small deposits to your account in 1–2 business days and an email with additional instructions to verify your bank account. -Expect a $0.01 deposit to the account ending in ••••%1$s in 1–2 business days and an email with additional instructions to verify your bank account. -Expect a $0.01 deposit to your account in 1–2 business days and an email with additional instructions to verify your bank account. -••••%1$s BANK STATEMENT -Link another account +Your bank information will be verified via micro-deposits to your account, typically within 1–2 business days. Only checking accounts are supported. +Use test account - Linking account
- Linking accounts
@@ -97,9 +100,6 @@Your account number couldn’t be accessed at this time Please enter your bank details manually Please enter your bank details manually or select another bank. -Are you sure you want to cancel? -You haven\'t finished linking your bank account. -If you cancel now, your account will be linked to %1$s but it will not be saved to Link. Yes, cancel Back Close @@ -109,13 +109,11 @@Please select another bank. Select the account you\'d like to link. Select all accounts -Finishing up -Hold on, we\'re almost done… We’ll use this to verify and manage your account. -Continue as -Not you? -Continue without signing in It looks like you have a Link account. Signing in will let you quickly access your saved bank accounts. -Sign in to Link +Use information you previously saved with your Link account. +Continue with Link +Continue with Link +Not now Save your account to Link Enter the code sent to %1$s Signing in as %1$s @@ -123,18 +121,29 @@Select an account to connect to this business New bank account Connect account -Check your email to confirm your identity -To keep your Link account safe, we periodically need to confirm you\'re you. Enter the code sent to your email %1$s. +Confirm your email +Enter the code sent to %1$s. We periodically request this extra step to keep your account safe. - Resend code Sign in to Link +Verify it\'s you Not now Hmm, that code didn’t work. Double check it and try again. It looks like the verification code you provided is not valid anymore. Get a new code and try again. It looks like the verification code you provided is not valid anymore. Click “Resend code” and try again, or Contact us .It looks like the verification code you provided is not valid anymore. Try again, or +Contact us .You\'re in test mode. We\'re experiencing high volumes. Try again in an hour and if you need immediate assistance, please contact us. Something went wrong. +Use test code Disconnected +Exit without connecting? +Your bank account won’t be connected to %1$s and all progress will be lost. +Your bank account won’t be connected to this business and all progress will be lost. +If you cancel now, your account will be linked to %1$s but it will not be saved to Link. +If you cancel now, your account will be linked to this business but it will not be saved to Link. +Yes, exit +Cancel +Cancel +Email address - Your account was connected to %1$s but could not be saved to Link with Stripe at this time.
- Your accounts were connected to %1$s but could not be saved to Link with Stripe at this time.
diff --git a/financial-connections/src/androidTest/java/com/stripe/android/financialconnections/navigation/DestinationMappersTest.kt b/financial-connections/src/androidTest/java/com/stripe/android/financialconnections/navigation/DestinationMappersTest.kt index 7c41642ae5f..0cb800a75e1 100644 --- a/financial-connections/src/androidTest/java/com/stripe/android/financialconnections/navigation/DestinationMappersTest.kt +++ b/financial-connections/src/androidTest/java/com/stripe/android/financialconnections/navigation/DestinationMappersTest.kt @@ -9,7 +9,6 @@ class DestinationMappersTest { // Panes that don't have a matching screen on the Android SDK side private val nonImplementedPanes = listOf( - Pane.UNEXPECTED_ERROR, Pane.AUTH_OPTIONS, Pane.LINK_CONSENT, Pane.LINK_LOGIN, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetActivity.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetActivity.kt index 11ffe2c77c2..fe3376f566f 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetActivity.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetActivity.kt @@ -8,7 +8,13 @@ import androidx.activity.addCallback import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.airbnb.mvrx.Mavericks import com.airbnb.mvrx.MavericksView import com.airbnb.mvrx.withState @@ -16,7 +22,7 @@ import com.stripe.android.financialconnections.FinancialConnectionsSheetViewEffe import com.stripe.android.financialconnections.FinancialConnectionsSheetViewEffect.OpenAuthFlowWithUrl import com.stripe.android.financialconnections.FinancialConnectionsSheetViewEffect.OpenNativeAuthFlow import com.stripe.android.financialconnections.browser.BrowserManager -import com.stripe.android.financialconnections.features.common.FullScreenGenericLoading +import com.stripe.android.financialconnections.features.common.LoadingSpinner import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityArgs import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityResult import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetNativeActivityArgs @@ -60,7 +66,12 @@ internal class FinancialConnectionsSheetActivity : AppCompatActivity(), Maverick @Composable private fun Loading() { FinancialConnectionsTheme { - FullScreenGenericLoading() + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + LoadingSpinner(Modifier.size(52.dp)) + } } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModel.kt index 675fc37a90f..c661f97c982 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModel.kt @@ -48,7 +48,6 @@ import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Named -@Suppress("LongParameterList", "TooManyFunctions") internal class FinancialConnectionsSheetViewModel @Inject constructor( @Named(APPLICATION_ID) private val applicationId: String, private val synchronizeFinancialConnectionsSession: SynchronizeFinancialConnectionsSession, @@ -139,7 +138,7 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor( } private fun logNoBrowserAvailableAndFinish() { - viewModelScope.launch { + withState { state -> val error = AppInitializationError("No Web browser available to launch AuthFlow") analyticsTracker.logError( "error Launching the Auth Flow", @@ -148,7 +147,7 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor( error = error ) finishWithResult( - state = awaitState(), + state = state, result = Failed(error) ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/analytics/FinancialConnectionsAnalyticsEvent.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/analytics/FinancialConnectionsAnalyticsEvent.kt index 555504d740e..5fb53422f34 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/analytics/FinancialConnectionsAnalyticsEvent.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/analytics/FinancialConnectionsAnalyticsEvent.kt @@ -196,6 +196,28 @@ internal sealed class FinancialConnectionsAnalyticsEvent( ).filterNotNullValues() ) + class AccountsSubmitted( + accountIds: Set, + isSkipAccountSelection: Boolean + ) : FinancialConnectionsAnalyticsEvent( + name = "account_picker.accounts_submitted", + mapOf( + "account_ids" to accountIds.joinToString(" "), + "is_skip_account_selection" to isSkipAccountSelection.toString(), + ).filterNotNullValues() + ) + + class AccountsAutoSelected( + accountIds: Set , + isSingleAccount: Boolean + ) : FinancialConnectionsAnalyticsEvent( + name = "account_picker.accounts_auto_selected", + mapOf( + "account_ids" to accountIds.joinToString(" "), + "is_single_account" to isSingleAccount.toString(), + ).filterNotNullValues() + ) + class PollAttachPaymentsSucceeded( authSessionId: String, duration: Long @@ -386,6 +408,13 @@ internal sealed class FinancialConnectionsAnalyticsEvent( ).filterNotNullValues() ) + class UncaughtException( + params: Map , + ) : FinancialConnectionsAnalyticsEvent( + name = "mobile.uncaught_exception", + params = params.filterNotNullValues(), + ) + override fun toString(): String { return "FinancialConnectionsEvent(name='$name', params=$params)" } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/analytics/FinancialConnectionsAnalyticsTracker.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/analytics/FinancialConnectionsAnalyticsTracker.kt index 4c616e00db0..5a7b25e094b 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/analytics/FinancialConnectionsAnalyticsTracker.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/analytics/FinancialConnectionsAnalyticsTracker.kt @@ -3,8 +3,8 @@ package com.stripe.android.financialconnections.analytics import android.content.Context import com.stripe.android.core.Logger import com.stripe.android.core.exception.StripeException +import com.stripe.android.core.networking.AnalyticsRequestV2Executor import com.stripe.android.core.networking.AnalyticsRequestV2Factory -import com.stripe.android.core.networking.StripeNetworkClient import com.stripe.android.financialconnections.FinancialConnections import com.stripe.android.financialconnections.FinancialConnectionsSheet import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.ErrorCode @@ -12,17 +12,20 @@ import com.stripe.android.financialconnections.analytics.FinancialConnectionsRes import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.exception.AppInitializationError import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import java.util.Locale /** * Event tracker for Financial Connections. */ -internal interface FinancialConnectionsAnalyticsTracker { - - suspend fun track(event: FinancialConnectionsAnalyticsEvent): Result +internal fun interface FinancialConnectionsAnalyticsTracker { + fun track(event: FinancialConnectionsAnalyticsEvent) } -internal suspend fun FinancialConnectionsAnalyticsTracker.logError( +internal fun FinancialConnectionsAnalyticsTracker.logError( extraMessage: String, error: Throwable, logger: Logger, @@ -78,12 +81,11 @@ private fun emitPublicClientErrorEventIfNeeded(error: Throwable) { } internal class FinancialConnectionsAnalyticsTrackerImpl( - private val stripeNetworkClient: StripeNetworkClient, private val getManifest: GetManifest, private val configuration: FinancialConnectionsSheet.Configuration, - private val logger: Logger, private val locale: Locale, context: Context, + private val requestExecutor: AnalyticsRequestV2Executor, ) : FinancialConnectionsAnalyticsTracker { private val requestFactory = AnalyticsRequestV2Factory( @@ -92,8 +94,9 @@ internal class FinancialConnectionsAnalyticsTrackerImpl( origin = ORIGIN ) - override suspend fun track(event: FinancialConnectionsAnalyticsEvent): Result { - return runCatching { + @OptIn(DelicateCoroutinesApi::class) + override fun track(event: FinancialConnectionsAnalyticsEvent) { + GlobalScope.launch(Dispatchers.IO) { val eventParams: Map = event.params ?: emptyMap() val commonParams = commonParams() val request = requestFactory.createRequest( @@ -101,12 +104,7 @@ internal class FinancialConnectionsAnalyticsTrackerImpl( additionalParams = eventParams + commonParams, includeSDKParams = true ) - stripeNetworkClient.executeRequest( - request - ) - logger.debug("EVENT: ${request.eventName}: ${request.params}") - }.onFailure { - logger.error("Exception while making analytics request", it) + requestExecutor.enqueue(request) } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeComponent.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeComponent.kt index 3db2098c58b..a4292f0278b 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeComponent.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeComponent.kt @@ -8,6 +8,8 @@ import com.stripe.android.financialconnections.features.accountpicker.AccountPic import com.stripe.android.financialconnections.features.attachpayment.AttachPaymentSubcomponent import com.stripe.android.financialconnections.features.bankauthrepair.BankAuthRepairSubcomponent import com.stripe.android.financialconnections.features.consent.ConsentSubcomponent +import com.stripe.android.financialconnections.features.error.ErrorSubcomponent +import com.stripe.android.financialconnections.features.exit.ExitSubcomponent import com.stripe.android.financialconnections.features.institutionpicker.InstitutionPickerSubcomponent import com.stripe.android.financialconnections.features.linkaccountpicker.LinkAccountPickerSubcomponent import com.stripe.android.financialconnections.features.linkstepupverification.LinkStepUpVerificationSubcomponent @@ -54,6 +56,8 @@ internal interface FinancialConnectionsSheetNativeComponent { val successSubcomponent: SuccessSubcomponent.Builder val attachPaymentSubcomponent: AttachPaymentSubcomponent.Builder val resetSubcomponent: ResetSubcomponent.Builder + val errorSubcomponent: ErrorSubcomponent.Factory + val exitSubcomponent: ExitSubcomponent.Factory val networkingLinkSignupSubcomponent: NetworkingLinkSignupSubcomponent.Builder val networkingLinkLoginWarmupSubcomponent: NetworkingLinkLoginWarmupSubcomponent.Builder val networkingLinkVerificationSubcomponent: NetworkingLinkVerificationSubcomponent.Builder diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeModule.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeModule.kt index 85d32d5ce20..65d5dbaae6f 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeModule.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeModule.kt @@ -8,6 +8,8 @@ import com.stripe.android.core.networking.ApiRequest import com.stripe.android.core.networking.StripeNetworkClient import com.stripe.android.core.version.StripeSdkVersion import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.domain.HandleError +import com.stripe.android.financialconnections.domain.RealHandleError import com.stripe.android.financialconnections.features.accountpicker.AccountPickerSubcomponent import com.stripe.android.financialconnections.features.attachpayment.AttachPaymentSubcomponent import com.stripe.android.financialconnections.features.consent.ConsentSubcomponent @@ -23,6 +25,7 @@ import com.stripe.android.financialconnections.network.FinancialConnectionsReque import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository import com.stripe.android.financialconnections.repository.FinancialConnectionsAccountsRepository import com.stripe.android.financialconnections.repository.FinancialConnectionsConsumerSessionRepository +import com.stripe.android.financialconnections.repository.FinancialConnectionsErrorRepository import com.stripe.android.financialconnections.repository.FinancialConnectionsInstitutionsRepository import com.stripe.android.financialconnections.repository.FinancialConnectionsManifestRepository import com.stripe.android.financialconnections.repository.SaveToLinkWithStripeSucceededRepository @@ -60,6 +63,11 @@ internal interface FinancialConnectionsSheetNativeModule { impl: NavigationManagerImpl ): NavigationManager + @Binds + fun bindsHandleError( + impl: RealHandleError + ): HandleError + companion object { @Provides @Singleton @@ -150,6 +158,14 @@ internal interface FinancialConnectionsSheetNativeModule { CoroutineScope(SupervisorJob() + workContext) ) + @Singleton + @Provides + fun providesFinancialConnectionsErrorRepository( + @IOContext workContext: CoroutineContext + ) = FinancialConnectionsErrorRepository( + CoroutineScope(SupervisorJob() + workContext) + ) + @Singleton @Provides fun providesPartnerToCoreAuthsRepository( diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetSharedModule.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetSharedModule.kt index 132b4afa822..34453738bd2 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetSharedModule.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetSharedModule.kt @@ -7,12 +7,16 @@ import com.stripe.android.core.injection.IOContext import com.stripe.android.core.injection.STRIPE_ACCOUNT_ID import com.stripe.android.core.networking.AnalyticsRequestExecutor import com.stripe.android.core.networking.AnalyticsRequestFactory +import com.stripe.android.core.networking.AnalyticsRequestV2Executor import com.stripe.android.core.networking.ApiRequest import com.stripe.android.core.networking.DefaultAnalyticsRequestExecutor +import com.stripe.android.core.networking.DefaultAnalyticsRequestV2Executor import com.stripe.android.core.networking.DefaultStripeNetworkClient import com.stripe.android.core.networking.NetworkTypeDetector import com.stripe.android.core.networking.StripeNetworkClient import com.stripe.android.core.utils.ContextUtils.packageInfo +import com.stripe.android.core.utils.IsWorkManagerAvailable +import com.stripe.android.core.utils.RealIsWorkManagerAvailable import com.stripe.android.financialconnections.FinancialConnectionsSheet import com.stripe.android.financialconnections.analytics.DefaultFinancialConnectionsEventReporter import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker @@ -21,8 +25,11 @@ import com.stripe.android.financialconnections.analytics.FinancialConnectionsEve import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.repository.FinancialConnectionsRepository import com.stripe.android.financialconnections.repository.FinancialConnectionsRepositoryImpl +import dagger.Binds import dagger.Module import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.serialization.json.Json import java.util.Locale import javax.inject.Named @@ -46,91 +53,108 @@ import kotlin.coroutines.CoroutineContext @Module( includes = [FinancialConnectionsSheetConfigurationModule::class] ) -internal object FinancialConnectionsSheetSharedModule { +internal interface FinancialConnectionsSheetSharedModule { - @Provides + @Binds @Singleton - internal fun providesApiOptions( - @Named(PUBLISHABLE_KEY) publishableKey: String, - @Named(STRIPE_ACCOUNT_ID) stripeAccountId: String? - ): ApiRequest.Options = ApiRequest.Options( - apiKey = publishableKey, - stripeAccount = stripeAccountId - ) + fun bindsAnalyticsRequestV2Executor(impl: DefaultAnalyticsRequestV2Executor): AnalyticsRequestV2Executor - @Provides - @Singleton - internal fun providesJson(): Json = Json { - coerceInputValues = true - ignoreUnknownKeys = true - isLenient = true - encodeDefaults = true - } + companion object { - @Provides - @Singleton - fun provideStripeNetworkClient( - @IOContext context: CoroutineContext, - logger: Logger - ): StripeNetworkClient = DefaultStripeNetworkClient( - workContext = context, - logger = logger - ) + @Provides + @Singleton + internal fun providesApiOptions( + @Named(PUBLISHABLE_KEY) publishableKey: String, + @Named(STRIPE_ACCOUNT_ID) stripeAccountId: String? + ): ApiRequest.Options = ApiRequest.Options( + apiKey = publishableKey, + stripeAccount = stripeAccountId + ) - @Singleton - @Provides - fun providesAnalyticsTracker( - context: Application, - logger: Logger, - getManifest: GetManifest, - locale: Locale?, - configuration: FinancialConnectionsSheet.Configuration, - stripeNetworkClient: StripeNetworkClient - ): FinancialConnectionsAnalyticsTracker = FinancialConnectionsAnalyticsTrackerImpl( - context = context, - configuration = configuration, - getManifest = getManifest, - logger = logger, - locale = locale ?: Locale.getDefault(), - stripeNetworkClient = stripeNetworkClient - ) + @Provides + @Singleton + internal fun providesJson(): Json = Json { + coerceInputValues = true + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } - @Provides - @Singleton - fun providesApiRequestFactory( - apiVersion: ApiVersion - ): ApiRequest.Factory = ApiRequest.Factory( - apiVersion = apiVersion.code - ) + @Provides + @Singleton + fun provideStripeNetworkClient( + @IOContext context: CoroutineContext, + logger: Logger + ): StripeNetworkClient = DefaultStripeNetworkClient( + workContext = context, + logger = logger + ) - @Provides - @Singleton - fun provideConnectionsRepository( - repository: FinancialConnectionsRepositoryImpl - ): FinancialConnectionsRepository = repository + @Singleton + @Provides + fun providesAnalyticsTracker( + context: Application, + getManifest: GetManifest, + locale: Locale?, + configuration: FinancialConnectionsSheet.Configuration, + requestExecutor: AnalyticsRequestV2Executor, + ): FinancialConnectionsAnalyticsTracker = FinancialConnectionsAnalyticsTrackerImpl( + context = context, + configuration = configuration, + getManifest = getManifest, + locale = locale ?: Locale.getDefault(), + requestExecutor = requestExecutor, + ) - @Provides - @Singleton - fun provideEventReporter( - defaultFinancialConnectionsEventReporter: DefaultFinancialConnectionsEventReporter - ): FinancialConnectionsEventReporter = defaultFinancialConnectionsEventReporter + @Provides + @Singleton + fun providesApiRequestFactory( + apiVersion: ApiVersion + ): ApiRequest.Factory = ApiRequest.Factory( + apiVersion = apiVersion.code + ) - @Provides - @Singleton - internal fun providesAnalyticsRequestExecutor( - executor: DefaultAnalyticsRequestExecutor - ): AnalyticsRequestExecutor = executor + @Provides + @Singleton + fun provideConnectionsRepository( + repository: FinancialConnectionsRepositoryImpl + ): FinancialConnectionsRepository = repository - @Provides - @Singleton - internal fun provideAnalyticsRequestFactory( - application: Application, - @Named(PUBLISHABLE_KEY) publishableKey: String - ): AnalyticsRequestFactory = AnalyticsRequestFactory( - packageManager = application.packageManager, - packageName = application.packageName.orEmpty(), - packageInfo = application.packageInfo, - publishableKeyProvider = { publishableKey }, - networkTypeProvider = NetworkTypeDetector(application)::invoke, - ) + @Provides + @Singleton + fun provideEventReporter( + defaultFinancialConnectionsEventReporter: DefaultFinancialConnectionsEventReporter + ): FinancialConnectionsEventReporter = defaultFinancialConnectionsEventReporter + + @Provides + @Singleton + internal fun providesAnalyticsRequestExecutor( + executor: DefaultAnalyticsRequestExecutor + ): AnalyticsRequestExecutor = executor + + @Provides + @Singleton + internal fun provideAnalyticsRequestFactory( + application: Application, + @Named(PUBLISHABLE_KEY) publishableKey: String + ): AnalyticsRequestFactory = AnalyticsRequestFactory( + packageManager = application.packageManager, + packageName = application.packageName.orEmpty(), + packageInfo = application.packageInfo, + publishableKeyProvider = { publishableKey }, + networkTypeProvider = NetworkTypeDetector(application)::invoke, + ) + + @Provides + @Singleton + internal fun providesIsWorkManagerAvailable(): IsWorkManagerAvailable { + return RealIsWorkManagerAvailable + } + + @Provides + @Singleton + internal fun providesIoDispatcher(): CoroutineDispatcher { + return Dispatchers.IO + } + } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/HandleError.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/HandleError.kt new file mode 100644 index 00000000000..ffc56b6c0fd --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/HandleError.kt @@ -0,0 +1,60 @@ +package com.stripe.android.financialconnections.domain + +import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.analytics.logError +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest +import com.stripe.android.financialconnections.navigation.Destination +import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.repository.FinancialConnectionsErrorRepository +import javax.inject.Inject + +internal interface HandleError { + operator fun invoke( + extraMessage: String, + error: Throwable, + pane: FinancialConnectionsSessionManifest.Pane, + displayErrorScreen: Boolean + ) +} + +internal class RealHandleError @Inject constructor( + private val errorRepository: FinancialConnectionsErrorRepository, + private val analyticsTracker: FinancialConnectionsAnalyticsTracker, + private val logger: Logger, + private val navigationManager: NavigationManager +) : HandleError { + + /** + * Handle an error by logging it and navigating to the error screen if necessary. + * + * - logs error to analytics. + * - logs error locally. + * - logs error to live events listener if needed. + * + * @param extraMessage a message to include in the analytics event + * @param error the error to handle + * @param pane the pane where the error occurred + * @param displayErrorScreen whether to navigate to the error screen + * + */ + override operator fun invoke( + extraMessage: String, + error: Throwable, + pane: FinancialConnectionsSessionManifest.Pane, + displayErrorScreen: Boolean + ) { + analyticsTracker.logError( + extraMessage = extraMessage, + error = error, + logger = logger, + pane = pane + ) + + // Navigate to error screen + if (displayErrorScreen) { + errorRepository.set(error) + navigationManager.tryNavigateTo(route = Destination.Error(referrer = pane)) + } + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/NativeAuthFlowCoordinator.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/NativeAuthFlowCoordinator.kt index ba296e5de57..37899efaa4d 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/NativeAuthFlowCoordinator.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/NativeAuthFlowCoordinator.kt @@ -35,5 +35,12 @@ internal class NativeAuthFlowCoordinator @Inject constructor() { USER_INITIATED_WITH_CUSTOM_MANUAL_ENTRY("user_initiated_with_custom_manual_entry") } } + + /** + * Triggers a termination of the AuthFlow with an exception. + */ + data class CloseWithError( + val cause: Throwable + ) : Message } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/NativeAuthFlowRouter.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/NativeAuthFlowRouter.kt index 6f07e8e2f45..7dac253bf40 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/NativeAuthFlowRouter.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/NativeAuthFlowRouter.kt @@ -25,7 +25,6 @@ internal class NativeAuthFlowRouter @Inject constructor( return killSwitchEnabled.not() && nativeExperimentEnabled } - @Suppress("ComplexCondition") suspend fun logExposure(manifest: FinancialConnectionsSessionManifest) { debugConfiguration.overriddenNative?.let { return } if (nativeKillSwitchActive(manifest).not()) { diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAttachPaymentAccount.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAttachPaymentAccount.kt index acdb1110a1e..e6863053003 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAttachPaymentAccount.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAttachPaymentAccount.kt @@ -40,9 +40,7 @@ internal class PollAttachPaymentAccount @Inject constructor( paymentAccount = params, consumerSessionClientSecret = consumerSessionClientSecret ) - } catch ( - @Suppress("SwallowedException") e: StripeException - ) { + } catch (e: StripeException) { throw e.toDomainException( activeInstitution, sync.showManualEntryInErrors() diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccounts.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccounts.kt index 87fac2926f1..b07fbed930a 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccounts.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccounts.kt @@ -56,7 +56,7 @@ internal class PollAuthorizationSessionAccounts @Inject constructor( accounts } } - } catch (@Suppress("SwallowedException") e: StripeException) { + } catch (e: StripeException) { throw e.toDomainException( institution = sync.manifest.activeInstitution, businessName = sync.manifest.getBusinessName(), diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PostAuthorizationSession.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PostAuthorizationSession.kt index 755b49fa8a4..f5ec658a931 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PostAuthorizationSession.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PostAuthorizationSession.kt @@ -39,9 +39,7 @@ internal class PostAuthorizationSession @Inject constructor( institution = institution, applicationId = applicationId ) - } catch ( - @Suppress("SwallowedException") e: StripeException - ) { + } catch (e: StripeException) { throw e.toDomainException( showManualEntry = sync.showManualEntryInErrors(), institution = institution diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/exception/FinancialConnectionsError.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/exception/FinancialConnectionsError.kt index 82692cc5e79..98e00222cc7 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/exception/FinancialConnectionsError.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/exception/FinancialConnectionsError.kt @@ -1,5 +1,6 @@ package com.stripe.android.financialconnections.exception +import com.stripe.android.core.StripeError import com.stripe.android.core.exception.StripeException /** @@ -15,5 +16,24 @@ internal abstract class FinancialConnectionsError( stripeException.cause, stripeException.message ) { + override fun analyticsValue(): String = "fcError" + + constructor( + name: String, + stripeError: StripeError? = null, + requestId: String? = null, + statusCode: Int = 0, + cause: Throwable? = null, + message: String? = stripeError?.message + ) : this( + name = name, + stripeException = object : StripeException( + stripeError = stripeError, + requestId = requestId, + statusCode = statusCode, + cause = cause, + message = message + ) {} + ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/exception/FinancialConnectionsErrorHandler.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/exception/FinancialConnectionsErrorHandler.kt new file mode 100644 index 00000000000..7f05062a157 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/exception/FinancialConnectionsErrorHandler.kt @@ -0,0 +1,35 @@ +package com.stripe.android.financialconnections.exception + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.analytics.toEventParams +import javax.inject.Inject + +internal class FinancialConnectionsErrorHandler @Inject constructor( + private val analyticsTracker: FinancialConnectionsAnalyticsTracker, +) { + + fun setup(lifecycleOwner: LifecycleOwner) { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + + Thread.setDefaultUncaughtExceptionHandler { thread, error -> + try { + val params = error.toEventParams(extraMessage = null) + analyticsTracker.track(FinancialConnectionsAnalyticsEvent.UncaughtException(params)) + } finally { + originalHandler?.uncaughtException(thread, error) + } + } + + lifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + super.onDestroy(owner) + } + } + ) + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/exception/PartnerAuthError.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/exception/PartnerAuthError.kt new file mode 100644 index 00000000000..20e639e5ff7 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/exception/PartnerAuthError.kt @@ -0,0 +1,6 @@ +package com.stripe.android.financialconnections.exception + +internal class PartnerAuthError(message: String?) : FinancialConnectionsError( + name = "PartnerAuthError", + message = message, +) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerPreviewParameterProvider.kt index 7d22b8e5192..22422523b05 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerPreviewParameterProvider.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerPreviewParameterProvider.kt @@ -1,39 +1,75 @@ -@file:Suppress("LongMethod") - package com.stripe.android.financialconnections.features.accountpicker import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Success -import com.stripe.android.financialconnections.features.common.AccessibleDataCalloutModel +import com.stripe.android.core.exception.APIException +import com.stripe.android.financialconnections.exception.AccountNoneEligibleForPaymentMethodError +import com.stripe.android.financialconnections.features.accountpicker.AccountPickerState.SelectionMode +import com.stripe.android.financialconnections.features.common.MerchantDataAccessModel +import com.stripe.android.financialconnections.model.Bullet +import com.stripe.android.financialconnections.model.ConnectedAccessNotice +import com.stripe.android.financialconnections.model.DataAccessNotice +import com.stripe.android.financialconnections.model.DataAccessNoticeBody import com.stripe.android.financialconnections.model.FinancialConnectionsAccount +import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution +import com.stripe.android.financialconnections.model.Image import com.stripe.android.financialconnections.model.PartnerAccount internal class AccountPickerPreviewParameterProvider : PreviewParameterProvider { override val values = sequenceOf( + loading(), + error(), multiSelect(), singleSelect(), - singleSelectWithConfirm() ) override val count: Int get() = super.count + private fun loading() = AccountPickerState( + payload = Loading(), + selectedIds = emptySet(), + ) + + private fun error() = AccountPickerState( + payload = Fail( + AccountNoneEligibleForPaymentMethodError( + accountsCount = 1, + institution = FinancialConnectionsInstitution( + id = "2", + name = "Institution 2", + url = "Institution 2 url", + featured = false, + featuredOrder = null, + icon = null, + logo = null, + mobileHandoffCapable = false + ), + merchantName = "Merchant name", + stripeException = APIException() + ) + ), + selectedIds = emptySet(), + ) + private fun multiSelect() = AccountPickerState( payload = Success( AccountPickerState.Payload( skipAccountSelection = false, accounts = partnerAccountList(), - selectionMode = AccountPickerState.SelectionMode.CHECKBOXES, - accessibleData = accessibleCallout(), + dataAccessNotice = dataAccessNotice(), + selectionMode = SelectionMode.Multiple, + merchantDataAccess = accessibleCallout(), singleAccount = false, stripeDirect = false, businessName = "Random business", userSelectedSingleAccountInInstitution = false, - requiresSingleAccountConfirmation = false ) ), - selectedIds = setOf("id1"), + selectedIds = setOf("id1", "id3"), ) private fun singleSelect() = AccountPickerState( @@ -41,33 +77,46 @@ internal class AccountPickerPreviewParameterProvider : AccountPickerState.Payload( skipAccountSelection = false, accounts = partnerAccountList(), - selectionMode = AccountPickerState.SelectionMode.RADIO, - accessibleData = accessibleCallout(), + dataAccessNotice = dataAccessNotice(), + selectionMode = SelectionMode.Single, + merchantDataAccess = accessibleCallout(), singleAccount = true, stripeDirect = false, businessName = "Random business", userSelectedSingleAccountInInstitution = false, - requiresSingleAccountConfirmation = false ) ), selectedIds = setOf("id1"), ) - private fun singleSelectWithConfirm() = AccountPickerState( - payload = Success( - AccountPickerState.Payload( - skipAccountSelection = false, - accounts = partnerAccountList(), - selectionMode = AccountPickerState.SelectionMode.RADIO, - accessibleData = accessibleCallout(), - singleAccount = true, - stripeDirect = false, - businessName = "Random business", - userSelectedSingleAccountInInstitution = false, - requiresSingleAccountConfirmation = true + private fun dataAccessNotice() = DataAccessNotice( + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Goldilocks uses Stripe to link your accounts", + subtitle = "Goldilocks will use your account and routing number, balances and transactions:", + body = DataAccessNoticeBody( + bullets = bullets() + ), + disclaimer = "Learn more about data access", + connectedAccountNotice = ConnectedAccessNotice( + subtitle = "Connected account placeholder", + body = DataAccessNoticeBody( + bullets = bullets() ) ), - selectedIds = setOf("id1"), + cta = "OK" + ) + + private fun bullets() = listOf( + Bullet( + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Account details", + content = "Account number, routing number, account type, account nickname." + ), + Bullet( + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Account details", + content = "Account number, routing number, account type, account nickname." + ), ) private fun partnerAccountList() = listOf( @@ -130,7 +179,7 @@ internal class AccountPickerPreviewParameterProvider : ), ) - private fun accessibleCallout() = AccessibleDataCalloutModel( + private fun accessibleCallout() = MerchantDataAccessModel( businessName = "My business", permissions = listOf( FinancialConnectionsAccount.Permissions.PAYMENT_METHOD, @@ -138,8 +187,6 @@ internal class AccountPickerPreviewParameterProvider : FinancialConnectionsAccount.Permissions.OWNERSHIP, FinancialConnectionsAccount.Permissions.TRANSACTIONS ), - isStripeDirect = false, - isNetworking = false, - dataPolicyUrl = "" + isStripeDirect = false ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerScreen.kt index 5abcfd913f4..19bb34ee754 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerScreen.kt @@ -1,30 +1,37 @@ -@file:Suppress("TooManyFunctions", "LongMethod") - package com.stripe.android.financialconnections.features.accountpicker import androidx.activity.compose.BackHandler -import androidx.compose.animation.Crossfade -import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Text +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import com.airbnb.mvrx.Async import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Success @@ -34,195 +41,277 @@ import com.airbnb.mvrx.compose.mavericksViewModel import com.stripe.android.financialconnections.R import com.stripe.android.financialconnections.exception.AccountLoadError import com.stripe.android.financialconnections.exception.AccountNoneEligibleForPaymentMethodError +import com.stripe.android.financialconnections.features.accountpicker.AccountPickerClickableText.DATA import com.stripe.android.financialconnections.features.accountpicker.AccountPickerState.SelectionMode -import com.stripe.android.financialconnections.features.common.AccessibleDataCallout -import com.stripe.android.financialconnections.features.common.AccessibleDataCalloutModel +import com.stripe.android.financialconnections.features.accountpicker.AccountPickerState.ViewEffect.OpenBottomSheet +import com.stripe.android.financialconnections.features.accountpicker.AccountPickerState.ViewEffect.OpenUrl import com.stripe.android.financialconnections.features.common.AccountItem -import com.stripe.android.financialconnections.features.common.LoadingContent +import com.stripe.android.financialconnections.features.common.DataAccessBottomSheetContent +import com.stripe.android.financialconnections.features.common.LoadingShimmerEffect +import com.stripe.android.financialconnections.features.common.MerchantDataAccessModel +import com.stripe.android.financialconnections.features.common.MerchantDataAccessText import com.stripe.android.financialconnections.features.common.NoAccountsAvailableErrorContent import com.stripe.android.financialconnections.features.common.NoSupportedPaymentMethodTypeAccountsErrorContent import com.stripe.android.financialconnections.features.common.UnclassifiedErrorContent -import com.stripe.android.financialconnections.model.FinancialConnectionsAccount import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.PartnerAccount import com.stripe.android.financialconnections.presentation.parentViewModel import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview -import com.stripe.android.financialconnections.ui.TextResource import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar +import com.stripe.android.financialconnections.ui.components.elevation import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.theme.LazyLayout +import com.stripe.android.financialconnections.ui.theme.Neutral900 +import kotlinx.coroutines.launch @Composable internal fun AccountPickerScreen() { val viewModel: AccountPickerViewModel = mavericksViewModel() val parentViewModel = parentViewModel() - BackHandler(true) {} val state: State = viewModel.collectAsState() + BackHandler(true) {} + + val bottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + skipHalfExpanded = true + ) + val uriHandler = LocalUriHandler.current + + state.value.viewEffect?.let { viewEffect -> + LaunchedEffect(viewEffect) { + when (viewEffect) { + is OpenUrl -> uriHandler.openUri(viewEffect.url) + is OpenBottomSheet -> bottomSheetState.show() + } + viewModel.onViewEffectLaunched() + } + } + AccountPickerContent( state = state.value, + bottomSheetState = bottomSheetState, onAccountClicked = viewModel::onAccountClicked, onSubmit = viewModel::onSubmit, - onSelectAllAccountsClicked = viewModel::onSelectAllAccountsClicked, onSelectAnotherBank = viewModel::selectAnotherBank, onEnterDetailsManually = viewModel::onEnterDetailsManually, onLoadAccountsAgain = viewModel::onLoadAccountsAgain, onCloseClick = { parentViewModel.onCloseWithConfirmationClick(Pane.ACCOUNT_PICKER) }, - onCloseFromErrorClick = parentViewModel::onCloseFromErrorClick, - onLearnMoreAboutDataAccessClick = viewModel::onLearnMoreAboutDataAccessClick + onClickableTextClick = viewModel::onClickableTextClick, + onCloseFromErrorClick = parentViewModel::onCloseFromErrorClick ) } @Composable private fun AccountPickerContent( state: AccountPickerState, + bottomSheetState: ModalBottomSheetState, onAccountClicked: (PartnerAccount) -> Unit, + onClickableTextClick: (String) -> Unit, onSubmit: () -> Unit, - onSelectAllAccountsClicked: () -> Unit, onSelectAnotherBank: () -> Unit, onEnterDetailsManually: () -> Unit, onLoadAccountsAgain: () -> Unit, onCloseClick: () -> Unit, - onLearnMoreAboutDataAccessClick: () -> Unit, onCloseFromErrorClick: (Throwable) -> Unit +) { + val scope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + ModalBottomSheetLayout( + sheetState = bottomSheetState, + sheetBackgroundColor = FinancialConnectionsTheme.colors.backgroundSurface, + sheetShape = RoundedCornerShape(8.dp), + scrimColor = Neutral900.copy(alpha = 0.32f), + sheetContent = { + when (val dataAccessNotice = state.payload()?.dataAccessNotice) { + null -> Unit + else -> DataAccessBottomSheetContent( + dataDialog = dataAccessNotice, + onConfirmModalClick = { scope.launch { bottomSheetState.hide() } }, + onClickableTextClick = onClickableTextClick + ) + } + }, + content = { + AccountPickerMainContent( + onCloseClick, + lazyListState, + state, + onSelectAnotherBank, + onEnterDetailsManually, + onLoadAccountsAgain, + onCloseFromErrorClick, + onAccountClicked, + onClickableTextClick, + onSubmit + ) + }, + ) +} + +@Composable +private fun AccountPickerMainContent( + onCloseClick: () -> Unit, + lazyListState: LazyListState, + state: AccountPickerState, + onSelectAnotherBank: () -> Unit, + onEnterDetailsManually: () -> Unit, + onLoadAccountsAgain: () -> Unit, + onCloseFromErrorClick: (Throwable) -> Unit, + onAccountClicked: (PartnerAccount) -> Unit, + onClickableTextClick: (String) -> Unit, + onSubmit: () -> Unit ) { FinancialConnectionsScaffold( topBar = { FinancialConnectionsTopAppBar( - showBack = false, - onCloseClick = onCloseClick + allowBackNavigation = false, + onCloseClick = onCloseClick, + elevation = lazyListState.elevation ) } ) { when (val payload = state.payload) { - Uninitialized, is Loading -> AccountPickerLoading() - is Success -> when (payload().shouldSkipPane) { - // ensures account picker is not shown momentarily - // if account selection should be skipped. - true -> AccountPickerLoading() - false -> AccountPickerLoaded( - submitEnabled = state.submitEnabled, - submitLoading = state.submitLoading, - accounts = payload().accounts, - allAccountsSelected = state.allAccountsSelected, - subtitle = payload().subtitle, - selectedIds = state.selectedIds, - onAccountClicked = onAccountClicked, - onSubmit = onSubmit, - selectionMode = payload().selectionMode, - accessibleDataCalloutModel = payload().accessibleData, - requiresSingleAccountConfirmation = payload().requiresSingleAccountConfirmation, - onSelectAllAccountsClicked = onSelectAllAccountsClicked, - onLearnMoreAboutDataAccessClick = onLearnMoreAboutDataAccessClick - ) - } + is Fail -> { + when (val error = payload.error) { + is AccountNoneEligibleForPaymentMethodError -> + NoSupportedPaymentMethodTypeAccountsErrorContent( + exception = error, + onSelectAnotherBank = onSelectAnotherBank + ) - is Fail -> when (val error = payload.error) { - is AccountNoneEligibleForPaymentMethodError -> - NoSupportedPaymentMethodTypeAccountsErrorContent( + is AccountLoadError -> NoAccountsAvailableErrorContent( exception = error, + onEnterDetailsManually = onEnterDetailsManually, + onTryAgain = onLoadAccountsAgain, onSelectAnotherBank = onSelectAnotherBank ) - is AccountLoadError -> NoAccountsAvailableErrorContent( - exception = error, - onEnterDetailsManually = onEnterDetailsManually, - onTryAgain = onLoadAccountsAgain, - onSelectAnotherBank = onSelectAnotherBank - ) - - else -> UnclassifiedErrorContent( - error = error, - onCloseFromErrorClick = onCloseFromErrorClick - ) + else -> UnclassifiedErrorContent( + error, + onCloseFromErrorClick = onCloseFromErrorClick + ) + } } + + is Loading, + is Uninitialized, + is Success -> AccountPickerLoaded( + payload = payload, + state = state, + onAccountClicked = onAccountClicked, + onClickableTextClick = onClickableTextClick, + lazyListState = lazyListState, + onSubmit = onSubmit + ) } } } @Composable -private fun AccountPickerLoading() { - LoadingContent( - title = stringResource(R.string.stripe_account_picker_loading_title), - content = stringResource(R.string.stripe_account_picker_loading_desc) +private fun AccountPickerLoaded( + payload: Async , + state: AccountPickerState, + lazyListState: LazyListState, + onAccountClicked: (PartnerAccount) -> Unit, + onClickableTextClick: (String) -> Unit, + onSubmit: () -> Unit +) { + LazyLayout( + lazyListState = lazyListState, + verticalArrangement = Arrangement.spacedBy(16.dp), + body = { + payload() + ?.takeIf { it.shouldSkipPane.not() } + ?.let { + loadedContent( + payload = it, + state = state, + onAccountClicked = onAccountClicked + ) + } ?: run { loadingContent() } + }, + footer = { + payload() + ?.takeIf { it.shouldSkipPane.not() } + ?.let { + Footer( + merchantDataAccessModel = it.merchantDataAccess, + onClickableTextClick = onClickableTextClick, + submitEnabled = state.submitEnabled, + submitLoading = state.submitLoading, + onSubmit = onSubmit, + selectedIds = state.selectedIds + ) + } + } + ) } +private fun LazyListScope.loadedContent( + payload: AccountPickerState.Payload, + state: AccountPickerState, + onAccountClicked: (PartnerAccount) -> Unit +) { + item { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource( + when (payload.selectionMode) { + SelectionMode.Single -> R.string.stripe_account_picker_singleselect_account + SelectionMode.Multiple -> R.string.stripe_account_picker_multiselect_account + } + ), + style = FinancialConnectionsTheme.typography.headingXLarge + ) + } + items(payload.accounts, key = { it.id }) { account -> + AccountItem( + selected = state.selectedIds.contains(account.id), + showInstitutionIcon = false, + onAccountClicked = onAccountClicked, + account = account, + ) + } +} + +private fun LazyListScope.loadingContent() { + item { + Text( + modifier = Modifier.fillMaxWidth(), + text = "Retrieving accounts", + style = FinancialConnectionsTheme.typography.headingXLarge + ) + } + items(3) { + LoadingShimmerEffect { + Box( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .clip(RoundedCornerShape(16.dp)) + .background(it) + ) + } + } +} + @Composable -private fun AccountPickerLoaded( +private fun Footer( + merchantDataAccessModel: MerchantDataAccessModel?, + onClickableTextClick: (String) -> Unit, submitEnabled: Boolean, submitLoading: Boolean, - accounts: List , - allAccountsSelected: Boolean, - accessibleDataCalloutModel: AccessibleDataCalloutModel?, - requiresSingleAccountConfirmation: Boolean, - selectionMode: SelectionMode, - selectedIds: Set , - onAccountClicked: (PartnerAccount) -> Unit, - onSelectAllAccountsClicked: () -> Unit, onSubmit: () -> Unit, - onLearnMoreAboutDataAccessClick: () -> Unit, - subtitle: TextResource? + selectedIds: Set ) { - Column( - Modifier - .fillMaxSize() - .padding( - top = 16.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) - ) { - Column( - modifier = Modifier - .weight(1f) - ) { - Text( - modifier = Modifier - .fillMaxWidth(), - text = stringResource( - when (requiresSingleAccountConfirmation) { - true -> R.string.stripe_account_picker_confirm_account - false -> when (selectionMode) { - SelectionMode.RADIO -> R.string.stripe_account_picker_singleselect_account - SelectionMode.CHECKBOXES -> R.string.stripe_account_picker_multiselect_account - } - } - ), - style = FinancialConnectionsTheme.typography.subtitle - ) - subtitle?.let { - Spacer(modifier = Modifier.size(8.dp)) - Text( - modifier = Modifier - .fillMaxWidth(), - text = it.toText().toString(), - style = FinancialConnectionsTheme.typography.body - ) - } - Spacer(modifier = Modifier.size(24.dp)) - when (selectionMode) { - SelectionMode.RADIO -> SingleSelectContent( - accounts = accounts, - selectedIds = selectedIds, - onAccountClicked = onAccountClicked - ) - - SelectionMode.CHECKBOXES -> MultiSelectContent( - accounts = accounts, - allAccountsSelected = allAccountsSelected, - selectedIds = selectedIds, - onAccountClicked = onAccountClicked, - onSelectAllAccountsClicked = onSelectAllAccountsClicked - ) - } - Spacer(modifier = Modifier.weight(1f)) - } - accessibleDataCalloutModel?.let { - AccessibleDataCallout( - it, - onLearnMoreAboutDataAccessClick + Column { + merchantDataAccessModel?.let { + MerchantDataAccessText( + model = it, + onLearnMoreClick = { onClickableTextClick(DATA.value) } ) } Spacer(modifier = Modifier.size(12.dp)) @@ -234,125 +323,16 @@ private fun AccountPickerLoaded( .fillMaxWidth() ) { Text( - text = when (requiresSingleAccountConfirmation) { - true -> stringResource(R.string.stripe_account_picker_cta_confirm) - false -> pluralStringResource( - count = selectedIds.size, - id = R.plurals.stripe_account_picker_cta_link - ) - } - ) - } - } -} + text = pluralStringResource( + count = selectedIds.size, + id = R.plurals.stripe_account_picker_cta_link + ) -@Composable -private fun SingleSelectContent( - accounts: List , - selectedIds: Set , - onAccountClicked: (PartnerAccount) -> Unit -) { - LazyColumn( - contentPadding = PaddingValues(bottom = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(accounts, key = { it.id }) { account -> - AccountItem( - selected = selectedIds.contains(account.id), - onAccountClicked = onAccountClicked, - account = account, - selectorContent = { - FinancialConnectionRadioButton( - checked = selectedIds.contains(account.id), - ) - }, ) } } } -@Composable -private fun MultiSelectContent( - accounts: List , - selectedIds: Set , - onAccountClicked: (PartnerAccount) -> Unit, - onSelectAllAccountsClicked: () -> Unit, - allAccountsSelected: Boolean -) { - LazyColumn( - contentPadding = PaddingValues(bottom = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - item("select_all_accounts") { - AccountItem( - selected = allAccountsSelected, - onAccountClicked = { onSelectAllAccountsClicked() }, - account = PartnerAccount( - id = "select_all_accounts", - _allowSelection = true, - allowSelectionMessage = "", - authorization = "", - category = FinancialConnectionsAccount.Category.UNKNOWN, - subcategory = FinancialConnectionsAccount.Subcategory.UNKNOWN, - name = stringResource(R.string.stripe_account_picker_select_all_accounts), - supportedPaymentMethodTypes = emptyList() - ), - ) { - FinancialConnectionCheckbox( - allAccountsSelected, - ) - } - } - items(accounts, key = { it.id }) { account -> - AccountItem( - selected = selectedIds.contains(account.id), - onAccountClicked = onAccountClicked, - account = account - ) { - FinancialConnectionCheckbox( - checked = selectedIds.contains(account.id), - ) - } - } - } -} - -@Composable -private fun FinancialConnectionCheckbox( - checked: Boolean, -) { - Crossfade(targetState = checked) { - Image( - painter = painterResource( - if (it) { - R.drawable.stripe_ic_checkbox_yes - } else { - R.drawable.stripe_ic_checkbox_no - }, - ), - contentDescription = null, - ) - } -} - -@Composable -private fun FinancialConnectionRadioButton( - checked: Boolean, -) { - Crossfade(targetState = checked) { - Image( - painter = painterResource( - if (it) { - R.drawable.stripe_ic_radio_yes - } else { - R.drawable.stripe_ic_radio_no - }, - ), - contentDescription = null, - ) - } -} - @Preview( showBackground = true, group = "Account Picker Pane", @@ -366,12 +346,16 @@ internal fun AccountPickerPreview( state = state, onAccountClicked = {}, onSubmit = {}, - onSelectAllAccountsClicked = {}, onSelectAnotherBank = {}, onEnterDetailsManually = {}, onLoadAccountsAgain = {}, onCloseClick = {}, - onLearnMoreAboutDataAccessClick = {} - ) {} + onCloseFromErrorClick = {}, + onClickableTextClick = {}, + bottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + skipHalfExpanded = true + ), + ) } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt index 83d4d535c9b..5ccb50fa241 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt @@ -9,8 +9,9 @@ import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext import com.stripe.android.core.Logger import com.stripe.android.financialconnections.FinancialConnections -import com.stripe.android.financialconnections.R import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AccountSelected +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AccountsAutoSelected +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AccountsSubmitted import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.ClickLearnMoreDataAccess import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.ClickLinkAccounts import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded @@ -21,9 +22,12 @@ import com.stripe.android.financialconnections.analytics.logError import com.stripe.android.financialconnections.domain.GetOrFetchSync import com.stripe.android.financialconnections.domain.PollAuthorizationSessionAccounts import com.stripe.android.financialconnections.domain.SelectAccounts +import com.stripe.android.financialconnections.features.accountpicker.AccountPickerClickableText.DATA import com.stripe.android.financialconnections.features.accountpicker.AccountPickerState.SelectionMode -import com.stripe.android.financialconnections.features.common.AccessibleDataCalloutModel -import com.stripe.android.financialconnections.features.consent.FinancialConnectionsUrlResolver +import com.stripe.android.financialconnections.features.accountpicker.AccountPickerState.ViewEffect +import com.stripe.android.financialconnections.features.accountpicker.AccountPickerState.ViewEffect.OpenBottomSheet +import com.stripe.android.financialconnections.features.common.MerchantDataAccessModel +import com.stripe.android.financialconnections.model.DataAccessNotice import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.PartnerAccount import com.stripe.android.financialconnections.model.PartnerAccountsList @@ -32,18 +36,19 @@ import com.stripe.android.financialconnections.navigation.Destination.Reset import com.stripe.android.financialconnections.navigation.NavigationManager import com.stripe.android.financialconnections.navigation.destination import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity -import com.stripe.android.financialconnections.ui.TextResource +import com.stripe.android.financialconnections.ui.HandleClickableUrl import com.stripe.android.financialconnections.utils.measureTimeMillis import kotlinx.coroutines.launch +import java.util.Date import javax.inject.Inject -@Suppress("LongParameterList", "TooManyFunctions") internal class AccountPickerViewModel @Inject constructor( initialState: AccountPickerState, private val eventTracker: FinancialConnectionsAnalyticsTracker, private val selectAccounts: SelectAccounts, private val getOrFetchSync: GetOrFetchSync, private val navigationManager: NavigationManager, + private val handleClickableUrl: HandleClickableUrl, private val logger: Logger, private val pollAuthorizationSessionAccounts: PollAuthorizationSessionAccounts ) : MavericksViewModel (initialState) { @@ -58,6 +63,7 @@ internal class AccountPickerViewModel @Inject constructor( suspend { val state = awaitState() val sync = getOrFetchSync() + val dataAccessNotice = sync.text?.consent?.dataAccessNotice val manifest = sync.manifest val activeAuthSession = requireNotNull(manifest.activeAuthSession) val (partnerAccountList, millis) = measureTimeMillis { @@ -80,22 +86,13 @@ internal class AccountPickerViewModel @Inject constructor( skipAccountSelection = partnerAccountList.skipAccountSelection == true || activeAuthSession.skipAccountSelection == true, accounts = accounts, - selectionMode = if (manifest.singleAccount) SelectionMode.RADIO else SelectionMode.CHECKBOXES, - accessibleData = AccessibleDataCalloutModel( + selectionMode = if (manifest.singleAccount) SelectionMode.Single else SelectionMode.Multiple, + dataAccessNotice = dataAccessNotice, + merchantDataAccess = MerchantDataAccessModel( businessName = manifest.businessName, permissions = manifest.permissions, - isNetworking = false, - isStripeDirect = manifest.isStripeDirect ?: false, - dataPolicyUrl = FinancialConnectionsUrlResolver.getDataPolicyUrl(manifest) + isStripeDirect = manifest.isStripeDirect ?: false ), - /** - * in the special case that this is single account and the institution would have - * skipped account selection but _didn't_ (because we still saw this), we should - * render specific text that tells the user to "confirm" their account. - */ - requiresSingleAccountConfirmation = activeAuthSession.institutionSkipAccountSelection == true && - manifest.singleAccount && - activeAuthSession.isOAuth, singleAccount = manifest.singleAccount, userSelectedSingleAccountInInstitution = manifest.singleAccount && activeAuthSession.institutionSkipAccountSelection == true && @@ -114,23 +111,40 @@ internal class AccountPickerViewModel @Inject constructor( // If account selection has to be skipped, submit all selectable accounts. payload.skipAccountSelection -> submitAccounts( selectedIds = payload.selectableAccounts.map { it.id }.toSet(), - updateLocalCache = false + updateLocalCache = false, + isSkipAccountSelection = true ) // the user saw an OAuth account selection screen and selected // just one to send back in a single-account context. treat these as if // we had done account selection, and submit. payload.userSelectedSingleAccountInInstitution -> submitAccounts( selectedIds = setOf(payload.accounts.first().id), - updateLocalCache = true + updateLocalCache = true, + isSkipAccountSelection = true ) - // Auto-select the first selectable account. - payload.selectionMode == SelectionMode.RADIO -> setState { - copy( - selectedIds = setOfNotNull( - payload.selectableAccounts.firstOrNull()?.id + // Auto-select the first selectable account and log. + payload.selectionMode == SelectionMode.Single -> { + val selectedId = setOfNotNull(payload.selectableAccounts.firstOrNull()?.id) + eventTracker.track( + AccountsAutoSelected( + isSingleAccount = true, + accountIds = selectedId ) ) + setState { copy(selectedIds = selectedId) } + } + + // Auto-select all selectable accounts and log. + payload.selectionMode == SelectionMode.Multiple -> { + val selectedIds = payload.selectableAccounts.map { it.id }.toSet() + eventTracker.track( + AccountsAutoSelected( + isSingleAccount = false, + accountIds = selectedIds + ) + ) + setState { copy(selectedIds = selectedIds) } } } }) @@ -165,8 +179,8 @@ internal class AccountPickerViewModel @Inject constructor( state.payload()?.let { payload -> val selectedIds = state.selectedIds val newSelectedIds = when (payload.selectionMode) { - SelectionMode.RADIO -> setOf(account.id) - SelectionMode.CHECKBOXES -> if (selectedIds.contains(account.id)) { + SelectionMode.Single -> setOf(account.id) + SelectionMode.Multiple -> if (selectedIds.contains(account.id)) { selectedIds - account.id } else { selectedIds + account.id @@ -219,7 +233,11 @@ internal class AccountPickerViewModel @Inject constructor( FinancialConnections.emitEvent(name = Name.ACCOUNTS_SELECTED) withState { state -> state.payload()?.let { - submitAccounts(state.selectedIds, updateLocalCache = true) + submitAccounts( + selectedIds = state.selectedIds, + updateLocalCache = true, + isSkipAccountSelection = false + ) } ?: run { logger.error("account clicked without available payload.") } @@ -228,9 +246,16 @@ internal class AccountPickerViewModel @Inject constructor( private fun submitAccounts( selectedIds: Set , - updateLocalCache: Boolean + updateLocalCache: Boolean, + isSkipAccountSelection: Boolean ) { suspend { + eventTracker.track( + AccountsSubmitted( + accountIds = selectedIds, + isSkipAccountSelection = isSkipAccountSelection + ) + ) val manifest = getOrFetchSync().manifest val accountsList: PartnerAccountsList = selectAccounts( selectedAccountIds = selectedIds, @@ -255,29 +280,25 @@ internal class AccountPickerViewModel @Inject constructor( loadAccounts() } - fun onSelectAllAccountsClicked() = withState { state -> - state.payload()?.let { payload -> - val selectedIds = state.selectedIds - val newIds = if (state.allAccountsSelected) { - // unselect all accounts - emptySet() - } else { - // select all accounts - payload.selectableAccounts.map { it.id }.toSet() - } - setState { copy(selectedIds = newIds) } - logAccountSelectionChanges( - idsBefore = selectedIds, - idsAfter = newIds, - isSingleAccount = payload.singleAccount + fun onClickableTextClick(uri: String) = viewModelScope.launch { + val date = Date() + handleClickableUrl( + currentPane = PANE, + uri = uri, + onNetworkUrlClicked = { + setState { copy(viewEffect = ViewEffect.OpenUrl(uri, date.time)) } + }, + knownDeeplinkActions = mapOf( + DATA.value to { + eventTracker.track(ClickLearnMoreDataAccess(PANE)) + setState { copy(viewEffect = OpenBottomSheet(date.time)) } + } ) - } + ) } - fun onLearnMoreAboutDataAccessClick() { - viewModelScope.launch { - eventTracker.track(ClickLearnMoreDataAccess(Pane.ACCOUNT_PICKER)) - } + fun onViewEffectLaunched() { + setState { copy(viewEffect = null) } } companion object : @@ -305,6 +326,7 @@ internal data class AccountPickerState( val canRetry: Boolean = true, val selectAccounts: Async = Uninitialized, val selectedIds: Set = emptySet(), + val viewEffect: ViewEffect? = null ) : MavericksState { val submitLoading: Boolean @@ -313,19 +335,16 @@ internal data class AccountPickerState( val submitEnabled: Boolean get() = selectedIds.isNotEmpty() - val allAccountsSelected: Boolean - get() = payload()?.selectableAccounts?.count() == selectedIds.count() - data class Payload( val skipAccountSelection: Boolean, val accounts: List , + val dataAccessNotice: DataAccessNotice?, val selectionMode: SelectionMode, - val accessibleData: AccessibleDataCalloutModel, + val merchantDataAccess: MerchantDataAccessModel, val singleAccount: Boolean, val stripeDirect: Boolean, val businessName: String?, val userSelectedSingleAccountInInstitution: Boolean, - val requiresSingleAccountConfirmation: Boolean ) { val selectableAccounts @@ -333,18 +352,24 @@ internal data class AccountPickerState( val shouldSkipPane: Boolean get() = skipAccountSelection || userSelectedSingleAccountInInstitution - - val subtitle: TextResource? - get() = when { - requiresSingleAccountConfirmation -> TextResource.StringId( - R.string.stripe_accountpicker_singleaccount_description - ) - - else -> null - } } enum class SelectionMode { - RADIO, CHECKBOXES + Single, Multiple } + + sealed class ViewEffect { + data class OpenUrl( + val url: String, + val id: Long + ) : ViewEffect() + + data class OpenBottomSheet( + val id: Long + ) : ViewEffect() + } +} + +internal enum class AccountPickerClickableText(val value: String) { + DATA("stripe://data-access-notice"), } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentScreen.kt index e028b027d1b..49acbbdf826 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentScreen.kt @@ -2,7 +2,6 @@ package com.stripe.android.financialconnections.features.attachpayment import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.tooling.preview.Preview import com.airbnb.mvrx.Async import com.airbnb.mvrx.Fail @@ -11,11 +10,9 @@ import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel -import com.stripe.android.financialconnections.R import com.stripe.android.financialconnections.exception.AccountNumberRetrievalError import com.stripe.android.financialconnections.features.common.AccountNumberRetrievalErrorContent import com.stripe.android.financialconnections.features.common.FullScreenGenericLoading -import com.stripe.android.financialconnections.features.common.LoadingContent import com.stripe.android.financialconnections.features.common.UnclassifiedErrorContent import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane.ATTACH_LINKED_PAYMENT_ACCOUNT import com.stripe.android.financialconnections.model.LinkAccountSessionPaymentAccount @@ -31,7 +28,6 @@ internal fun AttachPaymentScreen() { val state = viewModel.collectAsState() BackHandler(enabled = true) {} AttachPaymentContent( - payload = state.value.payload, attachPayment = state.value.linkPaymentAccount, onSelectAnotherBank = viewModel::onSelectAnotherBank, onEnterDetailsManually = viewModel::onEnterDetailsManually, @@ -42,7 +38,6 @@ internal fun AttachPaymentScreen() { @Composable private fun AttachPaymentContent( - payload: Async , attachPayment: Async , onSelectAnotherBank: () -> Unit, onEnterDetailsManually: () -> Unit, @@ -52,44 +47,17 @@ private fun AttachPaymentContent( FinancialConnectionsScaffold( topBar = { FinancialConnectionsTopAppBar( - showBack = false, + allowBackNavigation = false, onCloseClick = onCloseClick ) } ) { - when (payload) { - Uninitialized, is Loading -> FullScreenGenericLoading() - is Success -> when (attachPayment) { - is Loading, - is Uninitialized, - is Success -> LoadingContent( - title = pluralStringResource( - id = R.plurals.stripe_attachlinkedpaymentaccount_title, - count = payload().accountsCount - ), - content = when (val businessName = payload().businessName) { - null -> pluralStringResource( - id = R.plurals.stripe_attachlinkedpaymentaccount_desc, - count = payload().accountsCount - ) - - else -> pluralStringResource( - id = R.plurals.stripe_attachlinkedpaymentaccount_desc, - count = payload().accountsCount, - businessName - ) - } - ) - is Fail -> ErrorContent( - error = attachPayment.error, - onSelectAnotherBank = onSelectAnotherBank, - onEnterDetailsManually = onEnterDetailsManually, - onCloseFromErrorClick = onCloseFromErrorClick - ) - } - + when (attachPayment) { + is Loading, + is Uninitialized, + is Success -> FullScreenGenericLoading() is Fail -> ErrorContent( - error = payload.error, + error = attachPayment.error, onSelectAnotherBank = onSelectAnotherBank, onEnterDetailsManually = onEnterDetailsManually, onCloseFromErrorClick = onCloseFromErrorClick @@ -129,12 +97,6 @@ internal fun AttachPaymentScreenPreview() { FinancialConnectionsPreview { AttachPaymentContent( attachPayment = Loading(), - payload = Success( - AttachPaymentState.Payload( - accountsCount = 10, - businessName = "Random Business" - ) - ), onSelectAnotherBank = {}, onEnterDetailsManually = {}, onCloseClick = {} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentViewModel.kt index 774740818a6..284403e3825 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentViewModel.kt @@ -7,7 +7,6 @@ import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext import com.stripe.android.core.Logger -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PollAttachPaymentsSucceeded import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker import com.stripe.android.financialconnections.analytics.logError @@ -27,7 +26,6 @@ import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativ import com.stripe.android.financialconnections.utils.measureTimeMillis import javax.inject.Inject -@Suppress("LongParameterList") internal class AttachPaymentViewModel @Inject constructor( initialState: AttachPaymentState, private val saveToLinkWithStripeSucceeded: SaveToLinkWithStripeSucceededRepository, @@ -42,14 +40,6 @@ internal class AttachPaymentViewModel @Inject constructor( init { logErrors() - suspend { - val sync = getOrFetchSync() - val manifest = requireNotNull(sync.manifest) - AttachPaymentState.Payload( - businessName = manifest.businessName, - accountsCount = getCachedAccounts().size - ) - }.execute { copy(payload = it) } suspend { val sync = getOrFetchSync() val manifest = requireNotNull(sync.manifest) @@ -65,10 +55,10 @@ internal class AttachPaymentViewModel @Inject constructor( activeInstitution = activeInstitution, consumerSessionClientSecret = consumerSession?.clientSecret, params = PaymentAccountParams.LinkedAccount(requireNotNull(id)) - ).also { - val nextPane = it.nextPane ?: Pane.SUCCESS - navigationManager.tryNavigateTo(nextPane.destination(referrer = PANE)) - } + ) + } + if (manifest.isNetworkingUserFlow == true && manifest.accountholderIsLinkConsumer == true) { + result.networkingSuccessful?.let { saveToLinkWithStripeSucceeded.set(it) } } eventTracker.track( PollAttachPaymentsSucceeded( @@ -76,32 +66,16 @@ internal class AttachPaymentViewModel @Inject constructor( duration = millis ) ) + val nextPane = result.nextPane ?: Pane.SUCCESS + navigationManager.tryNavigateTo(nextPane.destination(referrer = PANE)) result }.execute { copy(linkPaymentAccount = it) } } private fun logErrors() { - onAsync( - AttachPaymentState::payload, - onFail = { - eventTracker.logError( - logger = logger, - pane = PANE, - extraMessage = "Error retrieving accounts to attach payment", - error = it - ) - }, - onSuccess = { - eventTracker.track(FinancialConnectionsAnalyticsEvent.PaneLoaded(PANE)) - } - ) onAsync( AttachPaymentState::linkPaymentAccount, - onSuccess = { - runCatching { saveToLinkWithStripeSucceeded.set(true) } - }, onFail = { - runCatching { saveToLinkWithStripeSucceeded.set(false) } eventTracker.logError( logger = logger, pane = PANE, @@ -138,11 +112,5 @@ internal class AttachPaymentViewModel @Inject constructor( } internal data class AttachPaymentState( - val payload: Async = Uninitialized, val linkPaymentAccount: Async = Uninitialized -) : MavericksState { - data class Payload( - val accountsCount: Int, - val businessName: String? - ) -} +) : MavericksState diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt index 6acaabda611..f469803e8dd 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt @@ -1,5 +1,3 @@ -@file:Suppress("LongMethod") - package com.stripe.android.financialconnections.features.bankauthrepair import androidx.compose.runtime.Composable @@ -18,10 +16,10 @@ internal fun BankAuthRepairScreen() { SharedPartnerAuth( state = state.value, onContinueClick = { /*TODO*/ }, - onSelectAnotherBank = { /*TODO*/ }, + onCancelClick = { /*TODO*/ }, onClickableTextClick = { /*TODO*/ }, - onEnterDetailsManually = { /*TODO*/ }, onWebAuthFlowFinished = { /*TODO*/ }, - onViewEffectLaunched = { /*TODO*/ } + onViewEffectLaunched = { /*TODO*/ }, + inModal = false ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/AccessibleDataCallout.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/AccessibleDataCallout.kt deleted file mode 100644 index 04ffb4bbd19..00000000000 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/AccessibleDataCallout.kt +++ /dev/null @@ -1,534 +0,0 @@ -@file:Suppress("TooManyFunctions", "LongMethod") - -package com.stripe.android.financialconnections.features.common - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Divider -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.model.FinancialConnectionsAccount -import com.stripe.android.financialconnections.model.FinancialConnectionsAccount.Permissions -import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution -import com.stripe.android.financialconnections.model.PartnerAccount -import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview -import com.stripe.android.financialconnections.ui.LocalImageLoader -import com.stripe.android.financialconnections.ui.TextResource -import com.stripe.android.financialconnections.ui.components.AnnotatedText -import com.stripe.android.financialconnections.ui.components.StringAnnotation -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography -import com.stripe.android.uicore.image.StripeImage - -private const val COLLAPSE_ACCOUNTS_THRESHOLD = 5 - -@Composable -internal fun AccessibleDataCallout( - model: AccessibleDataCalloutModel, - onLearnMoreClick: () -> Unit -) { - AccessibleDataCalloutBox { - AccessibleDataText( - model = model, - onLearnMoreClick = onLearnMoreClick - ) - } -} - -@Composable -internal fun AccessibleDataCalloutWithAccounts( - model: AccessibleDataCalloutModel, - institution: FinancialConnectionsInstitution, - accounts: List , - onLearnMoreClick: () -> Unit -) { - AccessibleDataCalloutBox { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (accounts.count() >= COLLAPSE_ACCOUNTS_THRESHOLD) { - AccountRow( - iconUrl = institution.icon?.default, - text = institution.name, - subText = stringResource( - id = R.string.stripe_success_infobox_accounts, - accounts.count() - ) - ) - } else { - accounts.forEach { - AccountRow( - iconUrl = institution.icon?.default, - text = listOfNotNull( - it.name, - it.redactedAccountNumbers - ).joinToString(" ") - ) - } - } - - Divider(color = colors.backgroundBackdrop) - AccessibleDataText( - model = model, - onLearnMoreClick = onLearnMoreClick - ) - } - } -} - -@Composable -private fun AccountRow( - text: String, - subText: String? = null, - iconUrl: String? -) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val modifier = Modifier - .size(24.dp) - .clip(RoundedCornerShape(4.dp)) - when { - iconUrl.isNullOrEmpty() -> InstitutionPlaceholder(modifier) - else -> StripeImage( - url = iconUrl, - imageLoader = LocalImageLoader.current, - contentDescription = null, - modifier = modifier, - errorContent = { InstitutionPlaceholder(modifier) } - ) - } - Text( - text, - style = typography.captionTightEmphasized, - color = colors.textSecondary - ) - } - if (subText != null) { - Text( - subText, - style = typography.captionTightEmphasized, - color = colors.textSecondary - ) - } - } -} - -@Composable -private fun AccessibleDataText( - model: AccessibleDataCalloutModel, - onLearnMoreClick: () -> Unit -) { - val uriHandler = LocalUriHandler.current - val permissionsReadable = remember(model.permissions) { model.permissions.toStringRes() } - AnnotatedText( - text = TextResource.StringId( - value = when { - model.isNetworking -> when (model.businessName) { - null -> R.string.stripe_data_accessible_callout_through_link_no_business - else -> R.string.stripe_data_accessible_callout_through_link - } - - model.isStripeDirect -> R.string.stripe_data_accessible_callout_stripe_direct - - else -> when (model.businessName) { - null -> R.string.stripe_data_accessible_callout_through_stripe_no_business - else -> R.string.stripe_data_accessible_callout_through_stripe - } - }, - args = listOfNotNull( - model.businessName, - readableListOfPermissions(permissionsReadable) - ) - ), - onClickableTextClick = { - uriHandler.openUri(model.dataPolicyUrl) - onLearnMoreClick() - }, - defaultStyle = typography.caption.copy( - color = colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.captionEmphasized - .toSpanStyle() - .copy(color = colors.textBrand), - StringAnnotation.BOLD to typography.captionEmphasized - .toSpanStyle() - .copy(color = colors.textSecondary) - ) - ) -} - -@Composable -private fun AccessibleDataCalloutBox( - content: @Composable BoxScope.() -> Unit -) { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(shape = RoundedCornerShape(8.dp)) - .background(color = colors.backgroundContainer) - .padding(12.dp), - content = content - ) -} - -@Composable -private fun readableListOfPermissions(permissionsReadable: List ): String = - permissionsReadable - // TODO@carlosmuvi localize enumeration of permissions once more languages are supported. - .map { stringResource(id = it) } - .foldIndexed("") { index, current, arg -> - when { - index == 0 -> arg.replaceFirstChar { char -> - if (char.isLowerCase()) char.titlecase() else char.toString() - } - - permissionsReadable.lastIndex == index -> "$current and $arg" - else -> "$current, $arg" - } - } - -private fun List .toStringRes(): List = mapNotNull { - when (it) { - Permissions.BALANCES -> R.string.stripe_data_accessible_type_balances - Permissions.OWNERSHIP -> R.string.stripe_data_accessible_type_ownership - Permissions.PAYMENT_METHOD, - Permissions.ACCOUNT_NUMBERS -> R.string.stripe_data_accessible_type_accountdetails - - Permissions.TRANSACTIONS -> R.string.stripe_data_accessible_type_transactions - Permissions.UNKNOWN -> null - } -}.distinct() - -internal data class AccessibleDataCalloutModel( - val businessName: String?, - val permissions: List , - val isStripeDirect: Boolean, - val isNetworking: Boolean, - val dataPolicyUrl: String -) - -@Preview( - group = "Data Callout", - name = "Default" -) -@Composable -internal fun AccessibleDataCalloutPreview() { - FinancialConnectionsPreview { - AccessibleDataCallout( - AccessibleDataCalloutModel( - businessName = "My business", - permissions = listOf( - Permissions.PAYMENT_METHOD, - Permissions.BALANCES, - Permissions.OWNERSHIP, - Permissions.TRANSACTIONS, - Permissions.ACCOUNT_NUMBERS - ), - isNetworking = false, - isStripeDirect = false, - dataPolicyUrl = "" - ), - onLearnMoreClick = {} - ) - } -} - -@Preview( - group = "Data Callout", - name = "Many Accounts" -) -@Composable -@Suppress("LongMethod") -internal fun AccessibleDataCalloutWithManyAccountsPreview() { - FinancialConnectionsPreview { - AccessibleDataCalloutWithAccounts( - AccessibleDataCalloutModel( - businessName = "My business", - permissions = listOf( - Permissions.PAYMENT_METHOD, - Permissions.BALANCES, - Permissions.OWNERSHIP, - Permissions.TRANSACTIONS - ), - isStripeDirect = false, - isNetworking = false, - dataPolicyUrl = "" - ), - accounts = partnerAccountsForPreview(), - institution = FinancialConnectionsInstitution( - id = "id", - name = "name", - url = "url", - featured = true, - icon = null, - logo = null, - featuredOrder = null, - mobileHandoffCapable = false - ), - onLearnMoreClick = {} - ) - } -} - -@Preview( - group = "Data Callout", - name = "Many Accounts and Stripe Direct" -) -@Composable -@Suppress("LongMethod") -internal fun AccessibleDataCalloutStripeDirectPreview() { - FinancialConnectionsPreview { - AccessibleDataCalloutWithAccounts( - AccessibleDataCalloutModel( - businessName = "My business", - permissions = listOf( - Permissions.PAYMENT_METHOD, - Permissions.BALANCES, - Permissions.OWNERSHIP, - Permissions.TRANSACTIONS - ), - isStripeDirect = true, - isNetworking = false, - dataPolicyUrl = "" - ), - accounts = partnerAccountsForPreview(), - institution = FinancialConnectionsInstitution( - id = "id", - name = "name", - url = "url", - featured = true, - icon = null, - logo = null, - featuredOrder = null, - mobileHandoffCapable = false - ), - onLearnMoreClick = {} - ) - } -} - -@Preview( - group = "Data Callout", - name = "Networking" -) -@Composable -@Suppress("LongMethod") -internal fun AccessibleDataCalloutNetworkingPreview() { - FinancialConnectionsPreview { - AccessibleDataCalloutWithAccounts( - AccessibleDataCalloutModel( - businessName = "My business", - permissions = listOf( - Permissions.PAYMENT_METHOD, - Permissions.BALANCES, - Permissions.OWNERSHIP, - Permissions.TRANSACTIONS - ), - isStripeDirect = false, - isNetworking = true, - dataPolicyUrl = "" - ), - accounts = partnerAccountsForPreview(), - institution = FinancialConnectionsInstitution( - id = "id", - name = "name", - url = "url", - featured = true, - icon = null, - logo = null, - featuredOrder = null, - mobileHandoffCapable = false - ), - onLearnMoreClick = {} - ) - } -} - -@Preview( - group = "Data Callout", - name = "Multiple accounts" -) -@Composable -internal fun AccessibleDataCalloutWithMultipleAccountsPreview() { - FinancialConnectionsPreview { - AccessibleDataCalloutWithAccounts( - AccessibleDataCalloutModel( - businessName = "My business", - permissions = listOf( - Permissions.PAYMENT_METHOD, - Permissions.BALANCES, - Permissions.OWNERSHIP, - Permissions.TRANSACTIONS - ), - isStripeDirect = true, - isNetworking = false, - dataPolicyUrl = "" - ), - accounts = listOf( - PartnerAccount( - authorization = "Authorization", - institutionName = "Random bank", - category = FinancialConnectionsAccount.Category.CASH, - id = "id1", - name = "Account 1", - balanceAmount = 1000, - displayableAccountNumbers = "1234", - currency = "$", - subcategory = FinancialConnectionsAccount.Subcategory.CHECKING, - _allowSelection = true, - allowSelectionMessage = "", - supportedPaymentMethodTypes = emptyList() - ), - PartnerAccount( - authorization = "Authorization", - category = FinancialConnectionsAccount.Category.CASH, - id = "id2", - name = "Account 2 - no acct numbers", - subcategory = FinancialConnectionsAccount.Subcategory.SAVINGS, - _allowSelection = true, - allowSelectionMessage = "", - supportedPaymentMethodTypes = emptyList() - ) - ), - institution = FinancialConnectionsInstitution( - id = "id", - name = "name", - url = "url", - featured = true, - icon = null, - logo = null, - featuredOrder = null, - mobileHandoffCapable = false - ), - onLearnMoreClick = {} - ) - } -} - -@Preview( - group = "Data Callout", - name = "One account" -) -@Composable -internal fun AccessibleDataCalloutWithOneAccountPreview() { - FinancialConnectionsPreview { - AccessibleDataCalloutWithAccounts( - AccessibleDataCalloutModel( - businessName = "My business", - permissions = listOf( - Permissions.PAYMENT_METHOD, - Permissions.BALANCES, - Permissions.OWNERSHIP, - Permissions.TRANSACTIONS - ), - isStripeDirect = true, - isNetworking = false, - dataPolicyUrl = "" - ), - accounts = listOf( - PartnerAccount( - authorization = "Authorization", - category = FinancialConnectionsAccount.Category.CASH, - id = "id1", - name = "Account 1", - balanceAmount = 1000, - displayableAccountNumbers = "1234", - currency = "$", - subcategory = FinancialConnectionsAccount.Subcategory.CHECKING, - _allowSelection = true, - allowSelectionMessage = "", - supportedPaymentMethodTypes = emptyList() - ) - ), - institution = FinancialConnectionsInstitution( - id = "id", - name = "name", - url = "url", - featured = true, - featuredOrder = null, - icon = null, - logo = null, - mobileHandoffCapable = false - ), - onLearnMoreClick = {} - ) - } -} - -@Composable -private fun partnerAccountsForPreview() = listOf( - PartnerAccount( - authorization = "Authorization", - institutionName = "Random bank", - category = FinancialConnectionsAccount.Category.CASH, - id = "id1", - name = "Account 1 - no acct numbers", - _allowSelection = true, - allowSelectionMessage = "", - subcategory = FinancialConnectionsAccount.Subcategory.CHECKING, - supportedPaymentMethodTypes = emptyList() - ), - PartnerAccount( - authorization = "Authorization", - category = FinancialConnectionsAccount.Category.CASH, - id = "id2", - name = "Account 2 - no acct numbers", - _allowSelection = true, - allowSelectionMessage = "", - subcategory = FinancialConnectionsAccount.Subcategory.SAVINGS, - supportedPaymentMethodTypes = emptyList() - ), - PartnerAccount( - authorization = "Authorization", - category = FinancialConnectionsAccount.Category.CASH, - id = "id3", - name = "Account 3 - no acct numbers", - _allowSelection = true, - allowSelectionMessage = "", - subcategory = FinancialConnectionsAccount.Subcategory.SAVINGS, - supportedPaymentMethodTypes = emptyList() - ), - PartnerAccount( - authorization = "Authorization", - category = FinancialConnectionsAccount.Category.CASH, - id = "id4", - name = "Account 4 - no acct numbers", - _allowSelection = true, - allowSelectionMessage = "", - subcategory = FinancialConnectionsAccount.Subcategory.SAVINGS, - supportedPaymentMethodTypes = emptyList() - ), - PartnerAccount( - authorization = "Authorization", - category = FinancialConnectionsAccount.Category.CASH, - id = "id5", - name = "Account 5 - no acct numbers", - _allowSelection = true, - allowSelectionMessage = "", - subcategory = FinancialConnectionsAccount.Subcategory.SAVINGS, - supportedPaymentMethodTypes = emptyList() - ) -) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/AccountItem.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/AccountItem.kt index 21f718d6f66..42dab02bd61 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/AccountItem.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/AccountItem.kt @@ -1,34 +1,48 @@ package com.stripe.android.financialconnections.features.common +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.M +import android.view.HapticFeedbackConstants.CONTEXT_CLICK +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.ConfigurationCompat.getLocales +import com.stripe.android.financialconnections.model.FinancialConnectionsAccount +import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution +import com.stripe.android.financialconnections.model.Image import com.stripe.android.financialconnections.model.NetworkedAccount import com.stripe.android.financialconnections.model.PartnerAccount -import com.stripe.android.financialconnections.ui.LocalImageLoader +import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold import com.stripe.android.financialconnections.ui.components.clickableSingle -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography import com.stripe.android.uicore.format.CurrencyFormatter -import com.stripe.android.uicore.image.StripeImage import com.stripe.android.uicore.text.MiddleEllipsisText import java.util.Locale @@ -39,26 +53,19 @@ import java.util.Locale * @param onAccountClicked callback when this account is clicked * @param account the account info to display * @param networkedAccount For networked accounts, extra info to display - * @param selectorContent content to display on the left side of the account item */ @Composable -@Suppress("LongMethod") internal fun AccountItem( selected: Boolean, + showInstitutionIcon: Boolean = true, onAccountClicked: (PartnerAccount) -> Unit, account: PartnerAccount, networkedAccount: NetworkedAccount? = null, - selectorContent: @Composable RowScope.() -> Unit ) { + val view = LocalView.current // networked account's allowSelection takes precedence over the account's. val selectable = networkedAccount?.allowSelection ?: account.allowSelection - val (title, subtitle) = getAccountTexts( - account = account, - networkedAccount = networkedAccount, - allowSelection = selectable - ) - val verticalPadding = remember(account) { if (subtitle != null) 10.dp else 12.dp } - val shape = remember { RoundedCornerShape(8.dp) } + val shape = remember { RoundedCornerShape(12.dp) } Box( modifier = Modifier .fillMaxWidth() @@ -66,85 +73,96 @@ internal fun AccountItem( .border( width = if (selected) 2.dp else 1.dp, color = when { - selected -> FinancialConnectionsTheme.colors.textBrand - else -> FinancialConnectionsTheme.colors.borderDefault + selected -> colors.borderBrand + else -> colors.border }, shape = shape ) - .clickableSingle(enabled = selectable) { onAccountClicked(account) } - .padding(vertical = verticalPadding, horizontal = 16.dp) + .clickableSingle(enabled = selectable) { + if (SDK_INT >= M) view.performHapticFeedback(CONTEXT_CLICK) + onAccountClicked(account) + } + .alpha(if (selectable) 1f else ContentAlpha.disabled) + .padding(16.dp) ) { Row( - horizontalArrangement = Arrangement.Start, + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - selectorContent() - Spacer(modifier = Modifier.size(16.dp)) + account.institution + ?.icon?.default + ?.takeIf { showInstitutionIcon }?.let { + InstitutionIcon(institutionIcon = it) + } Column( - Modifier.weight(ACCOUNT_COLUMN_WEIGHT) + Modifier.weight(1f) ) { Text( - text = title, + text = account.name, overflow = TextOverflow.Ellipsis, maxLines = 1, - color = if (selectable) { - FinancialConnectionsTheme.colors.textPrimary - } else { - FinancialConnectionsTheme.colors.textDisabled - }, - style = FinancialConnectionsTheme.typography.bodyEmphasized - ) - subtitle?.let { - Spacer(modifier = Modifier.size(4.dp)) - MiddleEllipsisText( - text = it, - color = FinancialConnectionsTheme.colors.textDisabled, - style = FinancialConnectionsTheme.typography.captionTight - ) - } - } - networkedAccount?.icon?.default?.let { - StripeImage( - url = it, - imageLoader = LocalImageLoader.current, - contentDescription = null, - modifier = Modifier - .size(16.dp) + color = colors.textDefault, + style = typography.labelLargeEmphasized ) + AccountSubtitle(selectable, account, networkedAccount) } + Icon( + modifier = Modifier + .size(16.dp) + .alpha(if (selected) 1f else 0f), + imageVector = Icons.Default.Check, + tint = colors.iconBrand, + contentDescription = "Selected" + ) } } } @Composable -private fun getAccountTexts( - allowSelection: Boolean, +private fun AccountSubtitle( + selectable: Boolean, account: PartnerAccount, networkedAccount: NetworkedAccount?, -): Pair { - val formattedBalance = account.getFormattedBalance() - - val subtitle = when { - networkedAccount?.caption != null -> networkedAccount.caption - allowSelection.not() && account.allowSelectionMessage?.isNotBlank() == true -> - account.allowSelectionMessage - - formattedBalance != null -> formattedBalance - account.redactedAccountNumbers != null -> account.redactedAccountNumbers - else -> null - } - - // just show redacted account numbers in the title if they're not in the subtitle. - val title = when { - subtitle != account.redactedAccountNumbers -> listOfNotNull( - account.name, - account.redactedAccountNumbers - ).joinToString(" ") +) { + val subtitle = getSubtitle(allowSelection = selectable, account = account, networkedAccount = networkedAccount) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + MiddleEllipsisText( + text = subtitle ?: account.redactedAccountNumbers, + color = colors.textSubdued, + style = typography.labelMedium + ) + account.getFormattedBalance() + // Only show balance if there is no custom subtitle (e.g. "Account unavailable, Repair account, etc") + ?.takeIf { subtitle == null } + ?.let { + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = it, + color = colors.textSubdued, + style = typography.labelSmall, + modifier = Modifier + .background( + color = colors.backgroundOffset, + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 4.dp) - else -> account.name + ) + } } +} - return title to subtitle +@Composable +private fun getSubtitle( + allowSelection: Boolean, + account: PartnerAccount, + networkedAccount: NetworkedAccount?, +): String? = when { + networkedAccount?.caption != null -> networkedAccount.caption + allowSelection.not() && account.allowSelectionMessage?.isNotBlank() == true -> account.allowSelectionMessage + else -> null } @Composable @@ -162,4 +180,121 @@ private fun PartnerAccount.getFormattedBalance(): String? { } } -private const val ACCOUNT_COLUMN_WEIGHT = 0.7f +@Composable +@Preview +internal fun AccountItemPreview() { + FinancialConnectionsPreview { + FinancialConnectionsScaffold(topBar = { }) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AccountItem( + selected = false, + onAccountClicked = { }, + account = PartnerAccount( + id = "id", + name = "Regular Checking (Unselected)", + allowSelectionMessage = "allowSelectionMessage", + institution = FinancialConnectionsInstitution( + id = "id", + name = "Bank of America", + featured = false, + mobileHandoffCapable = false, + icon = Image(default = "www.image.com") + ), + authorization = "", + currency = "USD", + category = FinancialConnectionsAccount.Category.CASH, + subcategory = FinancialConnectionsAccount.Subcategory.CHECKING, + supportedPaymentMethodTypes = emptyList(), + balanceAmount = 100, + ), + ) + AccountItem( + selected = true, + onAccountClicked = { }, + account = PartnerAccount( + id = "id", + name = "Regular Checking (Selected)", + allowSelectionMessage = "allowSelectionMessage", + institution = FinancialConnectionsInstitution( + id = "id", + name = "Bank of America", + featured = false, + mobileHandoffCapable = false, + icon = Image(default = "www.image.com") + ), + authorization = "", + currency = "USD", + category = FinancialConnectionsAccount.Category.CASH, + subcategory = FinancialConnectionsAccount.Subcategory.CHECKING, + supportedPaymentMethodTypes = emptyList(), + balanceAmount = 100, + ), + networkedAccount = NetworkedAccount( + id = "id", + allowSelection = true, + icon = Image(default = "www.image.com") + ) + ) + AccountItem( + selected = false, + onAccountClicked = { }, + account = PartnerAccount( + id = "id", + name = "Regular Checking (Disabled)", + _allowSelection = false, + allowSelectionMessage = null, + institution = FinancialConnectionsInstitution( + id = "id", + name = "Bank of America", + featured = false, + mobileHandoffCapable = false, + icon = Image(default = "www.image.com") + ), + authorization = "", + currency = "USD", + category = FinancialConnectionsAccount.Category.CASH, + subcategory = FinancialConnectionsAccount.Subcategory.CHECKING, + supportedPaymentMethodTypes = emptyList(), + balanceAmount = 100, + ), + networkedAccount = NetworkedAccount( + id = "id", + allowSelection = false, + icon = Image(default = "www.image.com") + ) + ) + AccountItem( + selected = false, + onAccountClicked = { }, + account = PartnerAccount( + id = "id", + name = "Regular Checking (Disabled)", + _allowSelection = false, + allowSelectionMessage = "Unselectable with custom message", + institution = FinancialConnectionsInstitution( + id = "id", + name = "Bank of America", + featured = false, + mobileHandoffCapable = false, + icon = Image(default = "www.image.com") + ), + authorization = "", + currency = "USD", + category = FinancialConnectionsAccount.Category.CASH, + subcategory = FinancialConnectionsAccount.Subcategory.CHECKING, + supportedPaymentMethodTypes = emptyList(), + balanceAmount = 100, + ), + networkedAccount = NetworkedAccount( + id = "id", + allowSelection = false, + icon = Image(default = "www.image.com") + ) + ) + } + } + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/CloseDialog.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/CloseDialog.kt deleted file mode 100644 index 05ec0390e78..00000000000 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/CloseDialog.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.stripe.android.financialconnections.features.common - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.AlertDialog -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.ui.TextResource -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme - -@Composable -internal fun CloseDialog( - description: TextResource, - onConfirmClick: () -> Unit, - onDismissClick: () -> Unit -) { - AlertDialog( - shape = RoundedCornerShape(8.dp), - backgroundColor = FinancialConnectionsTheme.colors.backgroundContainer, - onDismissRequest = onDismissClick, - title = { - Text( - text = stringResource(R.string.stripe_close_dialog_title), - ) - }, - text = { - Text( - text = description.toText().toString(), - ) - }, - confirmButton = { - TextButton( - onClick = onConfirmClick, - colors = ButtonDefaults.textButtonColors( - contentColor = FinancialConnectionsTheme.colors.textCritical - ), - ) { - Text(stringResource(R.string.stripe_close_dialog_confirm)) - } - }, - dismissButton = { - TextButton( - onClick = onDismissClick, - colors = ButtonDefaults.textButtonColors( - contentColor = FinancialConnectionsTheme.colors.textPrimary - ), - ) { - Text(stringResource(R.string.stripe_close_dialog_back)) - } - }, - ) -} - -@Preview(group = "Close Dialog", name = "Default") -@Composable -internal fun CloseDialogPreview() { - FinancialConnectionsTheme { - CloseDialog( - description = TextResource.StringId(R.string.stripe_close_dialog_desc), - onConfirmClick = {}, - onDismissClick = {} - ) - } -} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ConsumerSessionExtensions.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ConsumerSessionExtensions.kt index a4fd5569097..bf7405f5b71 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ConsumerSessionExtensions.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ConsumerSessionExtensions.kt @@ -3,7 +3,6 @@ import com.stripe.android.model.ConsumerSession /** * Mask the phone number to show only the last 4 digits. */ -@Suppress("MagicNumber") internal fun ConsumerSession.getRedactedPhoneNumber(): String { val number = redactedPhoneNumber.replace("*", "•") val formattedPhoneNumber = buildString { diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ErrorContent.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ErrorContent.kt index 2f7368fddb4..83a9a586f53 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ErrorContent.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ErrorContent.kt @@ -1,30 +1,18 @@ -@file:Suppress("TooManyFunctions") - package com.stripe.android.financialconnections.features.common -import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import android.os.Build +import android.view.HapticFeedbackConstants.REJECT +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -40,12 +28,11 @@ import com.stripe.android.financialconnections.exception.InstitutionPlannedDownt import com.stripe.android.financialconnections.exception.InstitutionUnplannedDowntimeError import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview -import com.stripe.android.financialconnections.ui.LocalImageLoader import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme -import com.stripe.android.uicore.image.StripeImage +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography +import com.stripe.android.financialconnections.ui.theme.LazyLayout import java.text.SimpleDateFormat @Composable @@ -54,7 +41,12 @@ internal fun UnclassifiedErrorContent( onCloseFromErrorClick: (Throwable) -> Unit ) { ErrorContent( - null, // TODO show warning icon. + iconContent = { + ShapedIcon( + painter = painterResource(id = R.drawable.stripe_ic_warning), + contentDescription = null + ) + }, title = stringResource(R.string.stripe_error_generic_title), content = stringResource(R.string.stripe_error_generic_desc), primaryCta = stringResource(R.string.stripe_error_cta_close) to { @@ -68,7 +60,12 @@ internal fun InstitutionUnknownErrorContent( onSelectAnotherBank: () -> Unit ) { ErrorContent( - iconUrl = null, // TODO show institution icon. + iconContent = { + ShapedIcon( + painter = painterResource(id = R.drawable.stripe_ic_warning), + contentDescription = null + ) + }, title = stringResource(R.string.stripe_error_generic_title), content = stringResource(R.string.stripe_error_unplanned_downtime_desc), primaryCta = Pair( @@ -85,7 +82,9 @@ internal fun InstitutionUnplannedDowntimeErrorContent( onEnterDetailsManually: () -> Unit ) { ErrorContent( - iconUrl = exception.institution.icon?.default ?: "", + iconContent = { + InstitutionIcon(institutionIcon = exception.institution.icon?.default ?: "") + }, title = stringResource( R.string.stripe_error_unplanned_downtime_title, exception.institution.name @@ -117,7 +116,9 @@ internal fun InstitutionPlannedDowntimeErrorContent( SimpleDateFormat("dd/MM/yyyy HH:mm", javaLocale).format(exception.backUpAt) } ErrorContent( - iconUrl = exception.institution.icon?.default ?: "", + iconContent = { + InstitutionIcon(institutionIcon = exception.institution.icon?.default ?: "") + }, title = stringResource( R.string.stripe_error_planned_downtime_title, exception.institution.name @@ -147,7 +148,9 @@ internal fun NoSupportedPaymentMethodTypeAccountsErrorContent( onSelectAnotherBank: () -> Unit ) { ErrorContent( - iconUrl = exception.institution.icon?.default ?: "", + iconContent = { + InstitutionIcon(institutionIcon = exception.institution.icon?.default ?: "") + }, title = stringResource( R.string.stripe_account_picker_error_no_payment_method_title ), @@ -205,7 +208,9 @@ internal fun NoAccountsAvailableErrorContent( } ErrorContent( - iconUrl = exception.institution.icon?.default ?: "", + iconContent = { + InstitutionIcon(institutionIcon = exception.institution.icon?.default ?: "") + }, title = stringResource( R.string.stripe_account_picker_error_no_account_available_title, exception.institution.name @@ -223,7 +228,9 @@ internal fun AccountNumberRetrievalErrorContent( onEnterDetailsManually: () -> Unit ) { ErrorContent( - iconUrl = exception.institution.icon?.default ?: "", + iconContent = { + InstitutionIcon(institutionIcon = exception.institution.icon?.default ?: "") + }, title = stringResource( R.string.stripe_attachlinkedpaymentaccount_error_title ), @@ -250,147 +257,54 @@ internal fun AccountNumberRetrievalErrorContent( @Composable internal fun ErrorContent( - iconUrl: String?, - badge: Pair = Pair( - painterResource(id = R.drawable.stripe_ic_warning_circle), - CircleShape - ), + iconContent: @Composable (() -> Unit)?, title: String, content: String, primaryCta: Pair Unit>? = null, secondaryCta: Pair Unit>? = null ) { - val scrollState = rememberScrollState() - Column( - Modifier - .padding( - top = 8.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) - .fillMaxSize() - ) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(scrollState) - ) { - BadgedInstitutionImage(iconUrl, badge) - Spacer(modifier = Modifier.size(16.dp)) - Text( - text = title, - style = FinancialConnectionsTheme.typography.subtitle - ) - Spacer(modifier = Modifier.size(16.dp)) - Text( - text = content, - style = FinancialConnectionsTheme.typography.body - ) + val view = LocalView.current + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.performHapticFeedback(REJECT) } - secondaryCta?.let { (text, onClick) -> - FinancialConnectionsButton( - type = FinancialConnectionsButton.Type.Secondary, - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - ) { - Text(text = text) + } + LazyLayout( + verticalArrangement = Arrangement.spacedBy(16.dp), + body = { + iconContent?.let { + item { Box(modifier = Modifier.padding(top = 16.dp)) { it() } } } - } - if (primaryCta != null && secondaryCta != null) Spacer(Modifier.size(8.dp)) - primaryCta?.let { (text, onClick) -> - FinancialConnectionsButton( - type = FinancialConnectionsButton.Type.Primary, - onClick = onClick, - modifier = Modifier - .fillMaxWidth() + item { Text(title, style = typography.headingXLarge) } + item { Text(content, style = typography.bodyMedium) } + }, + footer = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(text = text) + secondaryCta?.let { (text, onClick) -> + FinancialConnectionsButton( + type = FinancialConnectionsButton.Type.Secondary, + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = text) + } + } + primaryCta?.let { (text, onClick) -> + FinancialConnectionsButton( + type = FinancialConnectionsButton.Type.Primary, + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = text) + } + } } } - } -} - -@Composable -private fun BadgedInstitutionImage( - institutionIconUrl: String?, - badge: Pair -) { - Box( - modifier = Modifier - .size(40.dp) - ) { - val modifier = Modifier - .size(36.dp) - .align(Alignment.BottomStart) - .clip(RoundedCornerShape(6.dp)) - when { - institutionIconUrl.isNullOrEmpty() -> InstitutionPlaceholder(modifier) - else -> StripeImage( - url = institutionIconUrl, - imageLoader = LocalImageLoader.current, - errorContent = { InstitutionPlaceholder(modifier) }, - contentDescription = null, - modifier = modifier - ) - } - Icon( - painter = badge.first, - contentDescription = "", - tint = FinancialConnectionsTheme.colors.textCritical, - modifier = Modifier - .align(Alignment.TopEnd) - .size(12.dp) - .clip(badge.second) - // draws a background with padding around the badge to simulate a border. - .background(FinancialConnectionsTheme.colors.textWhite) - .padding(1.dp) - ) - } -} - -@Preview(group = "Errors", name = "unclassified error") -@Composable -internal fun UnclassifiedErrorContentPreview() { - FinancialConnectionsPreview { - FinancialConnectionsScaffold( - topBar = { FinancialConnectionsTopAppBar { } } - ) { - UnclassifiedErrorContent(APIException()) {} - } - } -} - -@Preview(group = "Errors", name = "institution down planned error") -@Composable -internal fun InstitutionPlannedDowntimeErrorContentPreview() { - FinancialConnectionsPreview { - FinancialConnectionsScaffold( - topBar = { FinancialConnectionsTopAppBar { } } - ) { - InstitutionPlannedDowntimeErrorContent( - exception = InstitutionPlannedDowntimeError( - institution = FinancialConnectionsInstitution( - id = "3", - name = "Random Institution", - url = "Random Institution url", - featured = false, - featuredOrder = null, - icon = null, - logo = null, - mobileHandoffCapable = false - ), - showManualEntry = true, - isToday = true, - backUpAt = 10000L, - stripeException = APIException() - ), - onEnterDetailsManually = {}, - onSelectAnotherBank = {} - ) - } - } + ) } @Preview(group = "Errors", name = "no accounts available error") @@ -423,13 +337,3 @@ internal fun NoAccountsAvailableErrorContentPreview() { } } } - -@Composable -internal fun InstitutionPlaceholder(modifier: Modifier) { - Image( - modifier = modifier, - painter = painterResource(id = R.drawable.stripe_ic_brandicon_institution), - contentDescription = "Bank icon placeholder", - contentScale = ContentScale.Crop - ) -} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/InstitutionIcon.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/InstitutionIcon.kt new file mode 100644 index 00000000000..c71c1e39c62 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/InstitutionIcon.kt @@ -0,0 +1,48 @@ +package com.stripe.android.financialconnections.features.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.ui.LocalImageLoader +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.uicore.image.StripeImage + +@Composable +internal fun InstitutionIcon(institutionIcon: String?) { + val previewMode = LocalInspectionMode.current + val iconModifier = Modifier + .size(56.dp) + .shadow(1.dp, RoundedCornerShape(12.dp), clip = true) + when { + previewMode || institutionIcon == null -> InstitutionPlaceholder(iconModifier) + else -> StripeImage( + url = institutionIcon, + imageLoader = LocalImageLoader.current, + contentDescription = null, + modifier = iconModifier, + contentScale = ContentScale.Crop, + loadingContent = { Box(modifier = iconModifier.background(colors.backgroundOffset)) }, + errorContent = { InstitutionPlaceholder(iconModifier) } + ) + } +} + +@Composable +private fun InstitutionPlaceholder(modifier: Modifier) { + Image( + modifier = modifier, + painter = painterResource(id = R.drawable.stripe_ic_brandicon_institution), + contentDescription = "Bank icon placeholder", + contentScale = ContentScale.Crop + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ListItem.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ListItem.kt new file mode 100644 index 00000000000..26eac665d96 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ListItem.kt @@ -0,0 +1,102 @@ +package com.stripe.android.financialconnections.features.common + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.ui.ImageResource +import com.stripe.android.financialconnections.ui.LocalImageLoader +import com.stripe.android.financialconnections.ui.TextResource +import com.stripe.android.financialconnections.ui.components.AnnotatedText +import com.stripe.android.financialconnections.ui.sdui.BulletUI +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography +import com.stripe.android.uicore.image.StripeImage + +@Composable +internal fun ListItem( + bullet: BulletUI, + onClickableTextClick: (String) -> Unit, +) { + val firstText = bullet.title ?: bullet.content ?: TextResource.Text("") + val secondText = remember(firstText) { bullet.content?.takeIf { bullet.title != null } } + val titleStyle = if (secondText != null) typography.bodyMediumEmphasized else typography.bodyMedium + Row { + ListItemIcon(icon = bullet.imageResource) + Spacer(modifier = Modifier.size(16.dp)) + Column { + AnnotatedText( + text = firstText, + defaultStyle = titleStyle.copy(color = FinancialConnectionsTheme.colors.textDefault), + onClickableTextClick = onClickableTextClick + ) + secondText?.let { + AnnotatedText( + text = requireNotNull(bullet.content), + defaultStyle = typography.bodySmall.copy(color = FinancialConnectionsTheme.colors.textSubdued), + onClickableTextClick = onClickableTextClick + ) + } + } + } +} + +@Composable +private fun ListItemIcon(icon: ImageResource?) { + val bulletColor = FinancialConnectionsTheme.colors.iconDefault + val iconSize = 20.dp + val modifier = Modifier + .size(iconSize) + .offset(y = 1.dp) + when (icon) { + // Render a bullet if no icon is provided + null -> Canvas( + modifier = modifier.padding((iconSize - 8.dp) / 2), + onDraw = { drawCircle(color = bulletColor) } + ) + + // Render the icon if it's a local resource + is ImageResource.Local -> Image( + modifier = modifier, + painter = painterResource(id = icon.resId), + contentDescription = null, + ) + + // Render the icon if it's a network resource, or fallback to a bullet if it fails to load + is ImageResource.Network -> StripeImage( + url = icon.url, + debugPainter = painterResource(id = R.drawable.stripe_ic_check_circle), + errorContent = { + Canvas( + modifier = modifier.padding((iconSize - 8.dp) / 2), + onDraw = { drawCircle(color = bulletColor) } + ) + }, + loadingContent = { + LoadingShimmerEffect { shimmer -> + Spacer( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .background(shimmer) + ) + } + }, + imageLoader = LocalImageLoader.current, + contentDescription = null, + modifier = modifier + ) + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/LoadingContent.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/LoadingContent.kt index ae4a9fbf0e6..ba60e8f9aab 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/LoadingContent.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/LoadingContent.kt @@ -1,116 +1,137 @@ package com.stripe.android.financialconnections.features.common -import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Text +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ProgressIndicatorDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors + +private const val SHIMMER_SIZE_MULTIPLIER = 0.2f +private const val SHIMMER_GRADIENT_ALPHA = 0.4f @Composable -@Suppress("MagicNumber") internal fun LoadingShimmerEffect( content: @Composable (Brush) -> Unit ) { + val screenWidthDp = with(LocalConfiguration.current) { screenWidthDp.dp } + val screenWidth = with(LocalDensity.current) { screenWidthDp.toPx() } + val shimmerWidth = screenWidth * SHIMMER_SIZE_MULTIPLIER + val gradient = listOf( - FinancialConnectionsTheme.colors.backgroundContainer, - FinancialConnectionsTheme.colors.textWhite, - FinancialConnectionsTheme.colors.backgroundContainer + colors.backgroundOffset, + Color.White.copy(alpha = SHIMMER_GRADIENT_ALPHA), + colors.backgroundOffset ) - val transition = rememberInfiniteTransition() + val transition = rememberInfiniteTransition(label = "shimmer_transition") val translateAnimation = transition.animateFloat( + label = "shimmer_translate_animation", initialValue = 0f, - targetValue = 1000f, + targetValue = screenWidth, animationSpec = infiniteRepeatable( animation = tween( - durationMillis = 1000, - easing = FastOutLinearInEasing + durationMillis = LOADING_SPINNER_ROTATION_MS, + easing = LinearEasing ) ) ) val brush = Brush.linearGradient( colors = gradient, - start = Offset(200f, 200f), + start = Offset( + translateAnimation.value - shimmerWidth, + translateAnimation.value - shimmerWidth + ), end = Offset( - x = translateAnimation.value, - y = translateAnimation.value + translateAnimation.value, + translateAnimation.value ) ) content(brush) } @Composable -internal fun LoadingContent( - modifier: Modifier = Modifier, - title: String? = null, - content: String? = null -) { - Column( - modifier = modifier - .padding(horizontal = 24.dp) - ) { - Spacer(modifier = Modifier.size(8.dp)) - LoadingSpinner() - if (title != null) { - Spacer(modifier = Modifier.size(16.dp)) - Text( - text = title, - style = FinancialConnectionsTheme.typography.subtitle - ) - } - if (content != null) { - Spacer(modifier = Modifier.size(16.dp)) - Text( - text = content, - style = FinancialConnectionsTheme.typography.body - ) - } +internal fun FullScreenGenericLoading() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LoadingSpinner(Modifier.size(52.dp)) } } @Composable -internal fun LoadingSpinner() { - val infiniteTransition = rememberInfiniteTransition() +internal fun LoadingSpinner( + modifier: Modifier = Modifier, + strokeWidth: Dp = ProgressIndicatorDefaults.StrokeWidth, + gradient: Brush = Brush.sweepGradient(listOf(colors.iconWhite, colors.borderBrand)) +) { + val infiniteTransition = rememberInfiniteTransition(label = "loading_transition") val angle by infiniteTransition.animateFloat( - initialValue = 0F, - targetValue = 360F, + label = "loading_animation", + initialValue = 0f, + targetValue = 360f, animationSpec = infiniteRepeatable( - animation = tween(LOADING_SPINNER_ROTATION_MS) - ) + animation = tween(LOADING_SPINNER_ROTATION_MS, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), ) - Image( - painter = painterResource(id = R.drawable.stripe_ic_loading_spinner), - modifier = Modifier.graphicsLayer { rotationZ = angle }, - contentDescription = "Loading spinner." - ) -} -@Composable -internal fun FullScreenGenericLoading() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator( - strokeWidth = 2.dp, - color = FinancialConnectionsTheme.colors.textSecondary, + Canvas(modifier = modifier) { + val diameter = size.minDimension + val radius = diameter / 2f + val strokePx = strokeWidth.toPx() + val arcDiameter = diameter - strokePx + val arcRadius = arcDiameter / 2f + val topLeftArc = Offset(radius - arcRadius, radius - arcRadius) + + withTransform( + transformBlock = { + rotate( + degrees = angle, + pivot = Offset(size.width / 2, size.height / 2) + ) + }, + drawBlock = { + drawArc( + brush = gradient, + startAngle = 90f, + sweepAngle = 260f, + useCenter = false, + topLeft = topLeftArc, + size = Size(arcDiameter, arcDiameter), + style = Stroke(width = strokePx, cap = StrokeCap.Round) + ) + } ) } } @@ -123,5 +144,50 @@ private const val LOADING_SPINNER_ROTATION_MS = 1000 ) @Composable internal fun LoadingSpinnerPreview() { - LoadingSpinner() + FinancialConnectionsPreview { + FinancialConnectionsScaffold( + topBar = { FinancialConnectionsTopAppBar(onCloseClick = {}) }, + content = { + FullScreenGenericLoading() + } + ) + } +} + +@Preview( + group = "Loading", + name = "Shimmer", + +) +@Composable +internal fun LoadingShimmerPreview() { + FinancialConnectionsPreview { + FinancialConnectionsScaffold( + topBar = { FinancialConnectionsTopAppBar(onCloseClick = {}) }, + content = { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + LoadingShimmerEffect { + Box( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .clip(RoundedCornerShape(16.dp)) + .background(it) + ) + } + LoadingShimmerEffect { + Box( + modifier = Modifier + .size(100.dp) + .clip(RoundedCornerShape(16.dp)) + .background(it) + ) + } + } + } + ) + } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ManifestExtensions.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ManifestExtensions.kt index 0989968e8f4..a1ba5c2d419 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ManifestExtensions.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ManifestExtensions.kt @@ -29,6 +29,10 @@ internal fun FinancialConnectionsSessionManifest.enableRetrieveAuthSession(): Bo features ?.get("bank_connections_disable_defensive_auth_session_retrieval_on_complete") != true +internal fun FinancialConnectionsSessionManifest.useContinueWithMerchantText(): Boolean = + features + ?.get("bank_connections_continue_with_merchant_text") == true + internal fun SynchronizeSessionResponse.showManualEntryInErrors(): Boolean { return manifest.allowManualEntry && visual.reducedManualEntryProminenceInErrors.not() } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/MerchantDataAccessText.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/MerchantDataAccessText.kt new file mode 100644 index 00000000000..9460c5ef981 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/MerchantDataAccessText.kt @@ -0,0 +1,139 @@ +package com.stripe.android.financialconnections.features.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.model.FinancialConnectionsAccount.Permissions +import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview +import com.stripe.android.financialconnections.ui.TextResource +import com.stripe.android.financialconnections.ui.components.AnnotatedText +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme + +@Composable +internal fun MerchantDataAccessText( + model: MerchantDataAccessModel, + onLearnMoreClick: () -> Unit +) { + val permissionsReadable = remember(model.permissions) { model.permissions.toStringRes() } + AnnotatedText( + modifier = Modifier.fillMaxWidth(), + text = TextResource.StringId( + value = when { + model.isStripeDirect -> R.string.stripe_data_accessible_callout_stripe + else -> when (model.businessName) { + null -> R.string.stripe_data_accessible_callout_business + else -> R.string.stripe_data_accessible_callout_no_business + } + }, + args = listOfNotNull( + model.businessName, + readableListOfPermissions(permissionsReadable) + ) + ), + onClickableTextClick = { + onLearnMoreClick() + }, + defaultStyle = FinancialConnectionsTheme.typography.labelSmall.copy( + color = FinancialConnectionsTheme.colors.textDefault, + textAlign = TextAlign.Center + ), + ) +} + +@Composable +private fun readableListOfPermissions(permissionsReadable: List ): String = + permissionsReadable + // TODO@carlosmuvi localize enumeration of permissions once more languages are supported. + .map { stringResource(id = it) } + .foldIndexed("") { index, current, arg -> + when { + index == 0 -> arg + permissionsReadable.lastIndex == index -> "$current and $arg" + else -> "$current, $arg" + } + } + +private fun List .toStringRes(): List = mapNotNull { + when (it) { + Permissions.BALANCES -> R.string.stripe_data_accessible_type_balances + Permissions.OWNERSHIP -> R.string.stripe_data_accessible_type_ownership + Permissions.PAYMENT_METHOD, + Permissions.ACCOUNT_NUMBERS -> R.string.stripe_data_accessible_type_accountdetails + + Permissions.TRANSACTIONS -> R.string.stripe_data_accessible_type_transactions + Permissions.UNKNOWN -> null + } +}.distinct() + +internal data class MerchantDataAccessModel( + val businessName: String?, + val permissions: List , + val isStripeDirect: Boolean, +) + +@Preview( + group = "Merchant data access text", + name = "Default" +) +@Composable +internal fun MerchantDataAccessTextPreview() { + FinancialConnectionsPreview { + FinancialConnectionsScaffold( + topBar = { /*TODO*/ } + ) { + Column( + Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // all permissions + MerchantDataAccessText( + MerchantDataAccessModel( + businessName = "My business", + permissions = listOf( + Permissions.PAYMENT_METHOD, + Permissions.BALANCES, + Permissions.OWNERSHIP, + Permissions.TRANSACTIONS, + Permissions.ACCOUNT_NUMBERS + ), + isStripeDirect = false, + ), + onLearnMoreClick = {} + ) + // one permission + MerchantDataAccessText( + MerchantDataAccessModel( + businessName = "My business", + permissions = listOf( + Permissions.TRANSACTIONS, + ), + isStripeDirect = false, + ), + onLearnMoreClick = {} + ) + // two permissions + MerchantDataAccessText( + MerchantDataAccessModel( + businessName = "My business", + permissions = listOf( + Permissions.TRANSACTIONS, + Permissions.OWNERSHIP, + ), + isStripeDirect = false, + ), + onLearnMoreClick = {} + ) + } + } + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ModalBottomSheetContent.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ModalBottomSheetContent.kt index 8b2d3b127fb..a30210083d5 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ModalBottomSheetContent.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ModalBottomSheetContent.kt @@ -1,38 +1,31 @@ -@file:Suppress("LongMethod") - package com.stripe.android.financialconnections.features.common -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.stripe.android.financialconnections.model.DataAccessNotice import com.stripe.android.financialconnections.model.LegalDetailsNotice -import com.stripe.android.financialconnections.ui.ImageResource -import com.stripe.android.financialconnections.ui.LocalImageLoader import com.stripe.android.financialconnections.ui.TextResource import com.stripe.android.financialconnections.ui.components.AnnotatedText import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton import com.stripe.android.financialconnections.ui.components.StringAnnotation import com.stripe.android.financialconnections.ui.sdui.BulletUI import com.stripe.android.financialconnections.ui.sdui.fromHtml +import com.stripe.android.financialconnections.ui.sdui.rememberHtml import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography -import com.stripe.android.uicore.image.StripeImage +import com.stripe.android.financialconnections.ui.theme.Layout @Composable internal fun DataAccessBottomSheetContent( @@ -40,30 +33,53 @@ internal fun DataAccessBottomSheetContent( onClickableTextClick: (String) -> Unit, onConfirmModalClick: () -> Unit ) { - val title = remember(dataDialog.title) { - TextResource.Text(fromHtml(dataDialog.title)) - } - val subtitle = remember(dataDialog.subtitle) { - dataDialog.subtitle?.let { TextResource.Text(fromHtml(it)) } - } - val learnMore = remember(dataDialog.learnMore) { - TextResource.Text(fromHtml(dataDialog.learnMore)) - } - val connectedAccountNotice = remember(dataDialog.connectedAccountNotice) { - dataDialog.connectedAccountNotice?.let { TextResource.Text(fromHtml(it)) } - } + val title = rememberHtml(dataDialog.title) + val subtitle = dataDialog.subtitle?.let { rememberHtml(it) } + val disclaimer = dataDialog.disclaimer?.let { rememberHtml(it) } val bullets = remember(dataDialog.body.bullets) { dataDialog.body.bullets.map { BulletUI.from(it) } } ModalBottomSheetContent( - title = title, - subtitle = subtitle, onClickableTextClick = onClickableTextClick, - bullets = bullets, - connectedAccountNotice = connectedAccountNotice, cta = dataDialog.cta, - learnMore = learnMore, + disclaimer = disclaimer, onConfirmModalClick = onConfirmModalClick, + content = { + dataDialog.icon?.default?.let { + ShapedIcon(url = it, contentDescription = "Icon") + Spacer(modifier = Modifier.size(16.dp)) + } + Title(title = title, onClickableTextClick = onClickableTextClick) + // FOR CONNECTED ACCOUNTS: Permissions granted to Stripe by the connected account + dataDialog.connectedAccountNotice?.let { + Spacer(modifier = Modifier.size(16.dp)) + Subtitle( + text = rememberHtml(it.subtitle), + onClickableTextClick = onClickableTextClick + ) + Spacer(modifier = Modifier.size(24.dp)) + it.body.bullets.forEach { bullet -> + ListItem( + bullet = BulletUI.from(bullet), + onClickableTextClick = onClickableTextClick + ) + Spacer(modifier = Modifier.size(16.dp)) + } + } + // FOR ALL MERCHANTS: Permissions granted to Stripe by the merchant + subtitle?.let { + Spacer(modifier = Modifier.size(16.dp)) + Subtitle(it, onClickableTextClick) + } + Spacer(modifier = Modifier.size(24.dp)) + bullets.forEach { bullet -> + ListItem( + bullet = bullet, + onClickableTextClick = onClickableTextClick + ) + Spacer(modifier = Modifier.size(16.dp)) + } + } ) } @@ -73,253 +89,136 @@ internal fun LegalDetailsBottomSheetContent( onClickableTextClick: (String) -> Unit, onConfirmModalClick: () -> Unit ) { - val title = remember(legalDetails.title) { - TextResource.Text(fromHtml(legalDetails.title)) - } - val learnMore = remember(legalDetails.learnMore) { - TextResource.Text(fromHtml(legalDetails.learnMore)) - } - val bullets = remember(legalDetails.body.bullets) { - legalDetails.body.bullets.map { BulletUI.from(it) } + val title = rememberHtml(legalDetails.title) + val subtitle = legalDetails.subtitle?.let { rememberHtml(it) } + val learnMore = legalDetails.disclaimer?.let { rememberHtml(it) } + val links = remember(legalDetails.body.links) { + legalDetails.body.links.map { TextResource.Text(fromHtml(it.title)) } } ModalBottomSheetContent( - title = title, - subtitle = null, onClickableTextClick = onClickableTextClick, - bullets = bullets, - connectedAccountNotice = null, cta = legalDetails.cta, - learnMore = learnMore, - onConfirmModalClick = onConfirmModalClick, - ) + disclaimer = learnMore, + onConfirmModalClick = onConfirmModalClick + ) { + legalDetails.icon?.default?.let { + ShapedIcon(it, contentDescription = "legal details icon") + Spacer(modifier = Modifier.size(16.dp)) + } + + Title(title = title, onClickableTextClick = onClickableTextClick) + + subtitle?.let { + Spacer(modifier = Modifier.size(16.dp)) + Subtitle(text = it, onClickableTextClick = onClickableTextClick) + } + + Spacer(modifier = Modifier.size(24.dp)) + + Links(links, onClickableTextClick) + } } @Composable -private fun ModalBottomSheetContent( - title: TextResource.Text, - subtitle: TextResource.Text?, +private fun Links( + links: List , onClickableTextClick: (String) -> Unit, - bullets: List , - connectedAccountNotice: TextResource?, - cta: String, - learnMore: TextResource, - onConfirmModalClick: () -> Unit, ) { - val scrollState = rememberScrollState() Column { - Column( - Modifier - .weight(1f, fill = false) - .verticalScroll(scrollState) - .padding(24.dp) - ) { + val linkStyle = typography.labelLargeEmphasized.copy(color = colors.textBrand) + links.forEachIndexed { index, link -> + Divider(color = colors.border) AnnotatedText( - text = title, - defaultStyle = typography.heading.copy( - color = colors.textPrimary + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + text = link, + defaultStyle = linkStyle, + annotationStyles = mapOf( + StringAnnotation.CLICKABLE to linkStyle.toSpanStyle() ), - annotationStyles = emptyMap(), - onClickableTextClick = onClickableTextClick - ) - subtitle?.let { - Spacer(modifier = Modifier.size(4.dp)) - AnnotatedText( - text = it, - defaultStyle = typography.body.copy( - color = colors.textPrimary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.detail - .toSpanStyle() - .copy(color = colors.textBrand), - StringAnnotation.BOLD to typography.detailEmphasized - .toSpanStyle() - .copy(color = colors.textPrimary), - ), - onClickableTextClick = onClickableTextClick - ) - } - bullets.forEach { - Spacer(modifier = Modifier.size(16.dp)) - BulletItem( - bullet = it, - onClickableTextClick = onClickableTextClick - ) - } - } - Column( - Modifier.padding( - bottom = 24.dp, - start = 24.dp, - end = 24.dp - ) - ) { - if (connectedAccountNotice != null) { - AnnotatedText( - text = connectedAccountNotice, - onClickableTextClick = onClickableTextClick, - defaultStyle = typography.caption.copy( - color = colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.captionEmphasized - .toSpanStyle() - .copy(color = colors.textBrand), - StringAnnotation.BOLD to typography.captionEmphasized - .toSpanStyle() - .copy(color = colors.textSecondary), - ) - ) - Spacer(modifier = Modifier.size(12.dp)) - } - AnnotatedText( - text = learnMore, onClickableTextClick = onClickableTextClick, - defaultStyle = typography.caption.copy( - color = colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.captionEmphasized - .toSpanStyle() - .copy(color = colors.textBrand), - StringAnnotation.BOLD to typography.captionEmphasized - .toSpanStyle() - .copy(color = colors.textSecondary), - ) ) - Spacer(modifier = Modifier.size(16.dp)) - FinancialConnectionsButton( - onClick = { onConfirmModalClick() }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = cta) + if (links.lastIndex == index) { + Divider(color = colors.border) } } } } @Composable -internal fun BulletItem( - bullet: BulletUI, +private fun Title( + title: TextResource.Text, onClickableTextClick: (String) -> Unit ) { - Row { - BulletIcon(icon = bullet.imageResource) - Spacer(modifier = Modifier.size(8.dp)) - Column { - when { - // title + content - bullet.title != null && bullet.content != null -> { - AnnotatedText( - text = bullet.title, - defaultStyle = typography.body.copy( - color = colors.textPrimary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.bodyEmphasized - .toSpanStyle() - .copy(color = colors.textBrand), - StringAnnotation.BOLD to typography.bodyEmphasized - .toSpanStyle() - .copy(color = colors.textPrimary), - ), - onClickableTextClick = onClickableTextClick - ) - Spacer(modifier = Modifier.size(2.dp)) - AnnotatedText( - text = bullet.content, - defaultStyle = typography.detail.copy( - color = colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.detailEmphasized - .toSpanStyle() - .copy(color = colors.textBrand), - StringAnnotation.BOLD to typography.detailEmphasized - .toSpanStyle() - .copy(color = colors.textSecondary), - ), - onClickableTextClick = onClickableTextClick - ) - } - // only title - bullet.title != null -> { - AnnotatedText( - text = bullet.title, - defaultStyle = typography.body.copy( - color = colors.textPrimary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.bodyEmphasized - .toSpanStyle() - .copy(color = colors.textBrand), - StringAnnotation.BOLD to typography.bodyEmphasized - .toSpanStyle() - .copy(color = colors.textPrimary), - ), - onClickableTextClick = onClickableTextClick - ) - } - // only content - bullet.content != null -> { - AnnotatedText( - text = bullet.content, - defaultStyle = typography.body.copy( - color = colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.bodyEmphasized - .toSpanStyle() - .copy(color = colors.textBrand), - StringAnnotation.BOLD to typography.bodyEmphasized - .toSpanStyle() - .copy(color = colors.textSecondary), - ), - onClickableTextClick = onClickableTextClick - ) - } - } - } - } + AnnotatedText( + text = title, + defaultStyle = typography.headingMedium.copy( + color = colors.textDefault + ), + onClickableTextClick = onClickableTextClick + ) } @Composable -private fun BulletIcon(icon: ImageResource?) { - val modifier = Modifier - .size(16.dp) - .offset(y = 2.dp) - if (icon == null) { - val color = colors.textPrimary - Canvas( - modifier = Modifier - .size(16.dp) - .padding(6.dp) - .offset(y = 2.dp), - onDraw = { drawCircle(color = color) } - ) - } else { - when (icon) { - is ImageResource.Local -> Image( - modifier = modifier, - painter = painterResource(id = icon.resId), - contentDescription = null, - ) - - is ImageResource.Network -> StripeImage( - url = icon.url, - errorContent = { - val color = colors.textSecondary - Canvas( - modifier = Modifier - .size(6.dp) - .align(Alignment.Center), - onDraw = { drawCircle(color = color) } - ) - }, - imageLoader = LocalImageLoader.current, - contentDescription = null, - modifier = modifier +private fun ModalBottomSheetContent( + onClickableTextClick: (String) -> Unit, + cta: String, + disclaimer: TextResource?, + onConfirmModalClick: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + Layout( + modifier = Modifier.padding(top = 32.dp), + inModal = true, + body = { content() }, + footer = { + ModalBottomSheetFooter( + onClickableTextClick = onClickableTextClick, + disclaimer = disclaimer, + onConfirmModalClick = onConfirmModalClick, + cta = cta ) } + ) +} + +@Composable +private fun Subtitle( + text: TextResource, + onClickableTextClick: (String) -> Unit +) { + AnnotatedText( + text = text, + defaultStyle = typography.bodyMedium.copy( + color = colors.textDefault + ), + onClickableTextClick = onClickableTextClick + ) +} + +@Composable +private fun ModalBottomSheetFooter( + onClickableTextClick: (String) -> Unit, + disclaimer: TextResource?, + onConfirmModalClick: () -> Unit, + cta: String +) = Column { + disclaimer?.let { + Spacer(modifier = Modifier.size(16.dp)) + AnnotatedText( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = it, + onClickableTextClick = onClickableTextClick, + defaultStyle = typography.labelSmall.copy( + color = colors.textDefault, + textAlign = TextAlign.Center + ), + ) + } + Spacer(modifier = Modifier.size(16.dp)) + FinancialConnectionsButton( + onClick = { onConfirmModalClick() }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = cta) } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/PaneFooter.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/PaneFooter.kt deleted file mode 100644 index 38406be3312..00000000000 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/PaneFooter.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.stripe.android.financialconnections.features.common - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Surface -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme - -/** - * A Composable function that displays a Pane footer with a given elevation and content. - * - * @param elevation The elevation of the Surface that wraps the Column content. - * @param content of the footer. - */ -@Composable -internal fun PaneFooter( - elevation: Dp, - content: @Composable ColumnScope.() -> Unit -) { - Surface( - color = FinancialConnectionsTheme.colors.backgroundSurface, - elevation = elevation - ) { - Column( - modifier = Modifier.padding( - start = 24.dp, - end = 24.dp, - top = 16.dp, - bottom = 24.dp - ), - content = content - ) - } -} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/PartnerCallout.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/PartnerCallout.kt deleted file mode 100644 index e12098391cc..00000000000 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/PartnerCallout.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.stripe.android.financialconnections.features.common - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import com.stripe.android.financialconnections.model.PartnerNotice -import com.stripe.android.financialconnections.ui.LocalImageLoader -import com.stripe.android.financialconnections.ui.TextResource -import com.stripe.android.financialconnections.ui.components.AnnotatedText -import com.stripe.android.financialconnections.ui.components.StringAnnotation -import com.stripe.android.financialconnections.ui.sdui.fromHtml -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme -import com.stripe.android.uicore.image.StripeImage - -@Composable -internal fun PartnerCallout( - modifier: Modifier = Modifier, - partnerNotice: PartnerNotice, - onClickableTextClick: (String) -> Unit -) { - Row( - modifier = modifier - .fillMaxWidth() - .clip(shape = RoundedCornerShape(8.dp)) - .background(color = FinancialConnectionsTheme.colors.backgroundContainer) - .padding(12.dp) - ) { - StripeImage( - url = partnerNotice.partnerIcon.default ?: "", - imageLoader = LocalImageLoader.current, - contentDescription = null, - modifier = Modifier - .size(24.dp) - .clip(RoundedCornerShape(6.dp)) - ) - Spacer(modifier = Modifier.size(16.dp)) - AnnotatedText( - TextResource.Text( - fromHtml(partnerNotice.text) - ), - defaultStyle = FinancialConnectionsTheme.typography.caption.copy( - color = FinancialConnectionsTheme.colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.captionEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textBrand), - StringAnnotation.BOLD to FinancialConnectionsTheme.typography.captionEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textSecondary) - ), - onClickableTextClick = onClickableTextClick - ) - } -} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ShapedIcon.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ShapedIcon.kt new file mode 100644 index 00000000000..44d95f39e39 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/ShapedIcon.kt @@ -0,0 +1,100 @@ +package com.stripe.android.financialconnections.features.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.ui.LocalImageLoader +import com.stripe.android.financialconnections.ui.theme.Brand50 +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.uicore.image.StripeImage + +private val iconSize = 20.dp + +/** + * A circular icon with a branded background color. + * + * @param painter the [Painter] to use for the icon + */ +@Composable +internal fun ShapedIcon( + painter: Painter, + backgroundShape: Shape = CircleShape, + contentDescription: String? +) { + CircleBox( + backgroundShape = backgroundShape + ) { + LocalIcon( + painter = painter, + contentDescription = contentDescription + ) + } +} + +/** + * A circular icon with a branded background color. + * + * @param url the URL to use for the icon + * @param errorPainter the [Painter] to use for the icon if the URL fails to load. If null, + * no icon will be rendered inside the circle. + */ +@Composable +internal fun ShapedIcon( + url: String, + backgroundShape: Shape = CircleShape, + contentDescription: String?, + errorPainter: Painter? = null +) { + CircleBox( + backgroundShape = backgroundShape, + ) { + StripeImage( + modifier = Modifier.size(iconSize), + url = url, + imageLoader = LocalImageLoader.current, + debugPainter = painterResource(id = R.drawable.stripe_ic_person), + contentDescription = contentDescription, + errorContent = { errorPainter?.let { LocalIcon(errorPainter, contentDescription) } }, + contentScale = ContentScale.Crop + ) + } +} + +@Composable +private fun LocalIcon( + painter: Painter, + contentDescription: String? +) { + Icon( + painter = painter, + tint = FinancialConnectionsTheme.colors.iconBrand, + contentDescription = contentDescription, + modifier = Modifier.size(iconSize), + ) +} + +@Composable +private fun CircleBox( + backgroundShape: Shape, + content: @Composable () -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(56.dp) + .background(color = Brand50, shape = backgroundShape) + ) { + content() + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt index e664943e83f..b0e88ba9ffb 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt @@ -3,38 +3,36 @@ package com.stripe.android.financialconnections.features.common import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.webkit.WebView -import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Text import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId @@ -50,10 +48,10 @@ import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.compose.collectAsState import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.exception.InstitutionPlannedDowntimeError -import com.stripe.android.financialconnections.exception.InstitutionUnplannedDowntimeError import com.stripe.android.financialconnections.features.partnerauth.PartnerAuthPreviewParameterProvider import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus.Action import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect import com.stripe.android.financialconnections.model.Entry import com.stripe.android.financialconnections.model.OauthPrepane @@ -64,23 +62,24 @@ import com.stripe.android.financialconnections.ui.LocalImageLoader import com.stripe.android.financialconnections.ui.TextResource import com.stripe.android.financialconnections.ui.components.AnnotatedText import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton.Type import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar -import com.stripe.android.financialconnections.ui.components.StringAnnotation import com.stripe.android.financialconnections.ui.sdui.fromHtml -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography +import com.stripe.android.financialconnections.ui.theme.LazyLayout import com.stripe.android.uicore.image.StripeImage -import kotlinx.coroutines.launch @Composable internal fun SharedPartnerAuth( state: SharedPartnerAuthState, onContinueClick: () -> Unit, - onSelectAnotherBank: () -> Unit, + onCancelClick: () -> Unit, onClickableTextClick: (String) -> Unit, - onEnterDetailsManually: () -> Unit, onWebAuthFlowFinished: (WebAuthFlowState) -> Unit, - onViewEffectLaunched: () -> Unit + onViewEffectLaunched: () -> Unit, + inModal: Boolean ) { val viewModel = parentViewModel() @@ -108,268 +107,306 @@ internal fun SharedPartnerAuth( } SharedPartnerAuthContent( - bottomSheetState = bottomSheetState, + inModal = inModal, state = state, onClickableTextClick = onClickableTextClick, - onSelectAnotherBank = onSelectAnotherBank, - onEnterDetailsManually = onEnterDetailsManually, - onCloseClick = { viewModel.onCloseWithConfirmationClick(state.pane) }, onContinueClick = onContinueClick, - onCloseFromErrorClick = viewModel::onCloseFromErrorClick + onCloseClick = { viewModel.onCloseWithConfirmationClick(state.pane) }, + onCancelClick = onCancelClick, ) } @Composable private fun SharedPartnerAuthContent( - bottomSheetState: ModalBottomSheetState, state: SharedPartnerAuthState, + inModal: Boolean, onClickableTextClick: (String) -> Unit, - onSelectAnotherBank: () -> Unit, - onEnterDetailsManually: () -> Unit, onContinueClick: () -> Unit, onCloseClick: () -> Unit, - onCloseFromErrorClick: (Throwable) -> Unit + onCancelClick: () -> Unit, ) { - val scope = rememberCoroutineScope() - ModalBottomSheetLayout( - sheetState = bottomSheetState, - sheetBackgroundColor = FinancialConnectionsTheme.colors.backgroundSurface, - sheetShape = RoundedCornerShape(8.dp), - scrimColor = FinancialConnectionsTheme.colors.textSecondary.copy(alpha = 0.5f), - sheetContent = { - state.dataAccess?.let { - DataAccessBottomSheetContent( - dataDialog = it, - onConfirmModalClick = { scope.launch { bottomSheetState.hide() } }, - onClickableTextClick = onClickableTextClick - ) - } ?: Spacer(modifier = Modifier.size(16.dp)) - }, - content = { - SharedPartnerAuthBody( - state = state, - onCloseClick = onCloseClick, - onSelectAnotherBank = onSelectAnotherBank, - onEnterDetailsManually = onEnterDetailsManually, - onCloseFromErrorClick = onCloseFromErrorClick, - onClickableTextClick = onClickableTextClick, - onContinueClick = onContinueClick, + SharedPartnerAuthBody( + inModal = inModal, + state = state, + onCloseClick = onCloseClick, + onClickableTextClick = onClickableTextClick, + onCancelClick = onCancelClick, + onContinueClick = onContinueClick, + ) +} + +@Composable +private fun SharedPartnerLoading(inModal: Boolean) { + LoadingShimmerEffect { shimmerBrush -> + Column( + Modifier.padding(horizontal = 24.dp) + ) { + Spacer(modifier = Modifier.size(24.dp)) + Box( + modifier = Modifier + .size(56.dp) + .background(shimmerBrush, RoundedCornerShape(8.dp)) + ) + Spacer(modifier = Modifier.size(16.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + .background(shimmerBrush, RoundedCornerShape(8.dp)) + + ) + Spacer(modifier = Modifier.size(16.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(16.dp) + .background(shimmerBrush, RoundedCornerShape(8.dp)) + + ) + Spacer(modifier = Modifier.size(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth(.50f) + .height(16.dp) + .background(shimmerBrush, RoundedCornerShape(8.dp)) + ) + if (inModal) { + Spacer(modifier = Modifier.height(16.dp)) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .background(shimmerBrush, RoundedCornerShape(8.dp)) + + ) + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .background(shimmerBrush, RoundedCornerShape(8.dp)) + + ) + Spacer(modifier = Modifier.size(24.dp)) } - ) + } } @Composable private fun SharedPartnerAuthBody( state: SharedPartnerAuthState, + inModal: Boolean, onCloseClick: () -> Unit, - onSelectAnotherBank: () -> Unit, - onEnterDetailsManually: () -> Unit, - onCloseFromErrorClick: (Throwable) -> Unit, + onCancelClick: () -> Unit, onContinueClick: () -> Unit, onClickableTextClick: (String) -> Unit ) { - FinancialConnectionsScaffold( - topBar = { - FinancialConnectionsTopAppBar( - showBack = state.canNavigateBack, - onCloseClick = onCloseClick - ) - } + SharedPartnerAuthContentWrapper( + inModal = inModal, + canNavigateBack = state.canNavigateBack, + onCloseClick = onCloseClick ) { - when (val payload = state.payload) { - Uninitialized, is Loading -> LoadingContent( - title = stringResource(id = R.string.stripe_partnerauth_loading_title), - content = stringResource(id = R.string.stripe_partnerauth_loading_desc) - ) - - is Fail -> ErrorContent( - error = payload.error, - onSelectAnotherBank = onSelectAnotherBank, - onEnterDetailsManually = onEnterDetailsManually, - onCloseFromErrorClick = onCloseFromErrorClick - ) - - is Success -> LoadedContent( + state.payload()?.let { + LoadedContent( + showInModal = inModal, authenticationStatus = state.authenticationStatus, - payload = payload(), - onClickableTextClick = onClickableTextClick, + payload = it, onContinueClick = onContinueClick, - onSelectAnotherBank = onSelectAnotherBank, + onCancelClick = onCancelClick, + onClickableTextClick = onClickableTextClick, ) - } + } ?: SharedPartnerLoading(inModal) } } +/** + * Wrapper for the content of the partner auth screen, that based on the [inModal] parameter + * will render the content in a modal or in a full screen. + */ @Composable -private fun ErrorContent( - error: Throwable, - onSelectAnotherBank: () -> Unit, - onEnterDetailsManually: () -> Unit, - onCloseFromErrorClick: (Throwable) -> Unit +private fun SharedPartnerAuthContentWrapper( + inModal: Boolean, + canNavigateBack: Boolean, + onCloseClick: () -> Unit, + content: @Composable () -> Unit ) { - when (error) { - is InstitutionPlannedDowntimeError -> InstitutionPlannedDowntimeErrorContent( - exception = error, - onSelectAnotherBank = onSelectAnotherBank, - onEnterDetailsManually = onEnterDetailsManually - ) - - is InstitutionUnplannedDowntimeError -> InstitutionUnplannedDowntimeErrorContent( - exception = error, - onSelectAnotherBank = onSelectAnotherBank, - onEnterDetailsManually = onEnterDetailsManually - ) - - else -> UnclassifiedErrorContent(error, onCloseFromErrorClick) + if (inModal) { + Box( + modifier = Modifier.fillMaxWidth() + ) { + content() + } + } else { + FinancialConnectionsScaffold( + topBar = { + FinancialConnectionsTopAppBar( + allowBackNavigation = canNavigateBack, + onCloseClick = onCloseClick + ) + } + ) { + content() + } } } @Composable private fun LoadedContent( - authenticationStatus: Async , + showInModal: Boolean, + authenticationStatus: Async , payload: SharedPartnerAuthState.Payload, onContinueClick: () -> Unit, - onSelectAnotherBank: () -> Unit, + onCancelClick: () -> Unit, onClickableTextClick: (String) -> Unit ) { when (authenticationStatus) { - is Uninitialized -> when (payload.authSession.isOAuth) { - true -> InstitutionalPrePaneContent( + is Uninitialized, + is Loading, + is Fail, + is Success -> when (payload.authSession.isOAuth) { + true -> PrePaneContent( + // show loading prepane when authenticationStatus + // is Loading or Success (completing auth after redirect) + authenticationStatus = authenticationStatus, + showInModal = showInModal, onContinueClick = onContinueClick, + onCancelClick = onCancelClick, content = requireNotNull(payload.authSession.display?.text?.oauthPrepane), onClickableTextClick = onClickableTextClick, ) - false -> LoadingContent( - title = stringResource(id = R.string.stripe_partnerauth_loading_title), - content = stringResource(id = R.string.stripe_partnerauth_loading_desc) - ) - } - - is Loading -> FullScreenGenericLoading() - - is Success -> LoadingContent( - title = stringResource(R.string.stripe_account_picker_loading_title), - content = stringResource(R.string.stripe_account_picker_loading_desc) - ) - - is Fail -> { - // TODO@carlosmuvi translate error type to specific error screen. - InstitutionUnknownErrorContent(onSelectAnotherBank) + false -> SharedPartnerLoading(showInModal) } } } -@OptIn(ExperimentalComposeUiApi::class) @Composable -@Suppress("LongMethod") -private fun InstitutionalPrePaneContent( - onContinueClick: () -> Unit, +private fun PrePaneContent( + showInModal: Boolean, content: OauthPrepane, - onClickableTextClick: (String) -> Unit + authenticationStatus: Async , + onContinueClick: () -> Unit, + onCancelClick: () -> Unit, + onClickableTextClick: (String) -> Unit, ) { - val title = remember(content.title) { - TextResource.Text(fromHtml(content.title)) - } - val scrollState = rememberScrollState() - Column( - Modifier - .fillMaxSize() - .padding( - top = 16.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) - ) { - content.institutionIcon?.default?.let { - val institutionIconModifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(6.dp)) - StripeImage( - url = it, - contentDescription = null, - imageLoader = LocalImageLoader.current, - errorContent = { InstitutionPlaceholder(institutionIconModifier) }, - modifier = institutionIconModifier - ) - Spacer(modifier = Modifier.size(16.dp)) - } - AnnotatedText( - text = title, - onClickableTextClick = { }, - defaultStyle = FinancialConnectionsTheme.typography.subtitle, - annotationStyles = mapOf( - StringAnnotation.BOLD to FinancialConnectionsTheme.typography.subtitleEmphasized.toSpanStyle() - ) - ) - Column( - modifier = Modifier - .padding(top = 16.dp, bottom = 16.dp) - .weight(1f) - .verticalScroll(scrollState) - ) { - // CONTENT - content.body.entries.forEachIndexed { index, bodyItem -> + LazyLayout( + inModal = showInModal, + // Overrides padding values to allow full-span prepane image background + verticalArrangement = Arrangement.spacedBy(24.dp), + bodyPadding = PaddingValues(top = 24.dp), + body = { + item { + PrepaneHeader( + modifier = Modifier.padding(horizontal = 24.dp), + content = content + ) + } + items(content.body.entries) { bodyItem -> when (bodyItem) { - is Entry.Image -> { - Box( - modifier = Modifier - .fillMaxWidth() - .background( - shape = RoundedCornerShape(8.dp), - color = FinancialConnectionsTheme.colors.backgroundContainer - ) - - ) { - Image( - painter = painterResource(id = R.drawable.stripe_prepane_phone_bg), - contentDescription = "Test", - contentScale = ContentScale.Crop, - modifier = Modifier - .align(Alignment.Center) - .width(PHONE_BACKGROUND_HEIGHT_DP.dp) - .height(PHONE_BACKGROUND_WIDTH_DP.dp) - ) - GifWebView( - modifier = Modifier - .align(Alignment.Center) - .width(PHONE_BACKGROUND_HEIGHT_DP.dp) - .height(PHONE_BACKGROUND_WIDTH_DP.dp) - .padding(horizontal = 16.dp), - bodyItem.content.default!! - ) - } - } + is Entry.Image -> PrepaneImage(bodyItem) is Entry.Text -> AnnotatedText( + modifier = Modifier.padding(horizontal = 24.dp), text = TextResource.Text(fromHtml(bodyItem.content)), onClickableTextClick = onClickableTextClick, - defaultStyle = FinancialConnectionsTheme.typography.body, - annotationStyles = mapOf( - StringAnnotation.BOLD to FinancialConnectionsTheme.typography.bodyEmphasized.toSpanStyle(), - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.bodyEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textBrand) - ) + defaultStyle = typography.bodyMedium ) } - if (index != content.body.entries.lastIndex) { - Spacer(modifier = Modifier.size(16.dp)) - } - } - Box(modifier = Modifier.weight(1f)) - content.partnerNotice?.let { - Spacer(modifier = Modifier.size(16.dp)) - PartnerCallout( - partnerNotice = content.partnerNotice, - onClickableTextClick = onClickableTextClick - ) } + }, + footer = { + PrepaneFooter( + onContinueClick = onContinueClick, + onCancelClick = onCancelClick, + status = authenticationStatus, + oAuthPrepane = content + ) } + ) +} + +@Composable +private fun PrepaneImage(bodyItem: Entry.Image) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .height(PHONE_BACKGROUND_HEIGHT_DP.dp) + ) { + // left gradient + Box( + modifier = Modifier + .background( + brush = Brush.horizontalGradient( + colors = listOf( + colors.backgroundOffset, + colors.border, + ), + ) + ) + .weight(1f) + .fillMaxHeight() + ) + + // left separator + Box( + modifier = Modifier + .background(color = colors.backgroundOffset) + .width(8.dp) + .fillMaxHeight() + ) + // image / gif + GifWebView( + modifier = Modifier + .width(PHONE_BACKGROUND_WIDTH_DP.dp) + .fillMaxHeight(), + bodyItem.content.default!! + ) + // right separator + Box( + modifier = Modifier + .background(color = colors.backgroundOffset) + .width(8.dp) + .fillMaxHeight() + ) + // right gradient + Box( + modifier = Modifier + .background( + brush = Brush.horizontalGradient( + colors = listOf( + colors.border, + colors.backgroundOffset, + ), + ) + ) + .weight(1f) + .fillMaxHeight() + ) + } +} + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +private fun PrepaneFooter( + onContinueClick: () -> Unit, + onCancelClick: () -> Unit, + status: Async , + oAuthPrepane: OauthPrepane +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { FinancialConnectionsButton( onClick = onContinueClick, + type = Type.Primary, + loading = status is Loading && status()?.action == Action.AUTHENTICATING, + enabled = status !is Loading, modifier = Modifier .semantics { testTagsAsResourceId = true } .testTag("prepane_cta") @@ -379,10 +416,10 @@ private fun InstitutionalPrePaneContent( verticalAlignment = Alignment.CenterVertically ) { Text( - text = content.cta.text, + text = oAuthPrepane.cta.text, textAlign = TextAlign.Center ) - content.cta.icon?.default?.let { + oAuthPrepane.cta.icon?.default?.let { Spacer(modifier = Modifier.size(12.dp)) StripeImage( url = it, @@ -394,6 +431,51 @@ private fun InstitutionalPrePaneContent( } } } + FinancialConnectionsButton( + onClick = onCancelClick, + type = Type.Secondary, + enabled = status !is Loading, + modifier = Modifier + .semantics { testTagsAsResourceId = true } + .testTag("cancel_cta") + .fillMaxWidth() + ) { + Text( + text = stringResource(R.string.stripe_prepane_cancel_cta), + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun PrepaneHeader( + content: OauthPrepane, + modifier: Modifier = Modifier +) { + val title = remember(content.title) { TextResource.Text(fromHtml(content.title)) } + val subtitle = remember(content.subtitle) { TextResource.Text(fromHtml(content.subtitle)) } + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + content.institutionIcon?.default?.let { + InstitutionIcon(institutionIcon = it) + } + AnnotatedText( + text = title, + onClickableTextClick = { }, + defaultStyle = typography.headingLarge.copy( + color = colors.textDefault + ), + ) + AnnotatedText( + text = subtitle, + onClickableTextClick = { }, + defaultStyle = typography.bodyMedium.copy( + color = colors.textDefault + ), + ) } } @@ -402,30 +484,45 @@ private fun GifWebView( modifier: Modifier, gifUrl: String ) { - val body = " " + val isPreview = LocalInspectionMode.current + val htmlContent = remember(gifUrl) { + buildString { + append("") + append("") + append("
") + append("") + } + } + val backgroundColor = colors.backgroundOffset.toArgb() AndroidView( - modifier = modifier, + modifier = modifier.background(Color.Transparent), factory = { WebView(it).apply { /** * WebView crashes when leaving the composition. Adding alpha acts as a workaround. * https://stackoverflow.com/questions/74829526/ */ + setBackgroundColor(backgroundColor) alpha = WEBVIEW_ALPHA layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) - isVerticalScrollBarEnabled = false - isVerticalFadingEdgeEnabled = false - loadData(body, null, "UTF-8") + if (!isPreview) { + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + isVerticalFadingEdgeEnabled = false + } + loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null) } }, update = { - it.loadData(body, null, "UTF-8") + it.loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null) } ) } @Preview( - group = "Shared Partner Auth" + group = "SharedPartnerAuth" ) @Composable internal fun PartnerAuthPreview( @@ -435,19 +532,37 @@ internal fun PartnerAuthPreview( FinancialConnectionsPreview { SharedPartnerAuthContent( state = state, - bottomSheetState = rememberModalBottomSheetState( - ModalBottomSheetValue.Hidden, - ), - onContinueClick = {}, - onSelectAnotherBank = {}, - onEnterDetailsManually = {}, + inModal = false, onClickableTextClick = {}, + onContinueClick = {}, onCloseClick = {}, - onCloseFromErrorClick = {} + onCancelClick = {} ) } } -private const val PHONE_BACKGROUND_WIDTH_DP = 272 -private const val PHONE_BACKGROUND_HEIGHT_DP = 264 +@Preview( + group = "SharedPartnerAuth - Drawer" +) +@Composable +internal fun PartnerAuthDrawerPreview( + @PreviewParameter(PartnerAuthPreviewParameterProvider::class) + state: SharedPartnerAuthState +) { + FinancialConnectionsPreview { + Box(modifier = Modifier.background(Color.White)) { + SharedPartnerAuthContent( + state = state, + inModal = true, + onClickableTextClick = {}, + onContinueClick = {}, + onCloseClick = {}, + onCancelClick = {} + ) + } + } +} + +private const val PHONE_BACKGROUND_WIDTH_DP = 240 +private const val PHONE_BACKGROUND_HEIGHT_DP = 200 private const val WEBVIEW_ALPHA = 0.99f diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/VerificationSection.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/VerificationSection.kt index f3a998bfb2d..3d52fef6c0a 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/VerificationSection.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/VerificationSection.kt @@ -1,27 +1,34 @@ package com.stripe.android.financialconnections.features.common +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES +import android.view.HapticFeedbackConstants import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.material.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import com.stripe.android.financialconnections.R import com.stripe.android.financialconnections.domain.ConfirmVerification.OTPError import com.stripe.android.financialconnections.domain.ConfirmVerification.OTPError.Type import com.stripe.android.financialconnections.features.consent.FinancialConnectionsUrlResolver +import com.stripe.android.financialconnections.ui.LocalTestMode import com.stripe.android.financialconnections.ui.TextResource import com.stripe.android.financialconnections.ui.components.AnnotatedText import com.stripe.android.financialconnections.ui.components.StringAnnotation -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.components.TestModeBanner +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography import com.stripe.android.financialconnections.ui.theme.StripeThemeForConnections import com.stripe.android.uicore.elements.OTPElement import com.stripe.android.uicore.elements.OTPElementUI @@ -33,16 +40,41 @@ internal fun VerificationSection( enabled: Boolean, confirmVerificationError: Throwable?, ) { + val view = LocalView.current Column { StripeThemeForConnections { + if (LocalTestMode.current) { + TestModeBanner( + enabled = enabled, + buttonLabel = stringResource(R.string.stripe_verification_useTestCode), + onButtonClick = otpElement::populateTestCode, + ) + + Spacer(modifier = Modifier.height(24.dp)) + } + OTPElementUI( + otpInputPlaceholder = "", + boxSpacing = 8.dp, + middleSpacing = 8.dp, + boxTextStyle = typography.headingXLargeSubdued.copy( + color = colors.textDefault, + textAlign = TextAlign.Center + ), focusRequester = focusRequester, enabled = enabled, element = otpElement ) } + LaunchedEffect(confirmVerificationError) { + if (confirmVerificationError is OTPError) { + if (SDK_INT >= VERSION_CODES.R) { + view.performHapticFeedback(HapticFeedbackConstants.REJECT) + } + } + } if (confirmVerificationError is OTPError) { - Spacer(modifier = Modifier.size(4.dp)) + Spacer(modifier = Modifier.size(16.dp)) VerificationErrorText( error = confirmVerificationError, ) @@ -58,32 +90,24 @@ private fun VerificationErrorText( error: OTPError, ) { val uriHandler = LocalUriHandler.current - Row { - Icon( - modifier = Modifier - .size(12.dp) - .offset(y = 2.dp), - painter = painterResource(R.drawable.stripe_ic_warning), - contentDescription = "Warning icon", - tint = FinancialConnectionsTheme.colors.textCritical - ) - AnnotatedText( - modifier = Modifier.padding(horizontal = 4.dp), - text = error.toMessage(), - defaultStyle = FinancialConnectionsTheme.typography.caption.copy( - color = FinancialConnectionsTheme.colors.textCritical - ), - onClickableTextClick = { - uriHandler.openUri(FinancialConnectionsUrlResolver.linkVerificationSupportUrl) - }, - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.caption.copy( - color = FinancialConnectionsTheme.colors.textCritical, - textDecoration = TextDecoration.Underline - ).toSpanStyle() - ), - ) - } + AnnotatedText( + modifier = Modifier.fillMaxWidth(), + text = error.toMessage(), + defaultStyle = typography.labelMedium.copy( + color = colors.textCritical, + textAlign = TextAlign.Center + ), + onClickableTextClick = { + uriHandler.openUri(FinancialConnectionsUrlResolver.linkVerificationSupportUrl) + }, + annotationStyles = mapOf( + StringAnnotation.CLICKABLE to typography.labelMedium.copy( + color = colors.textCritical, + textDecoration = TextDecoration.Underline, + textAlign = TextAlign.Center + ).toSpanStyle() + ), + ) } private fun OTPError.toMessage(): TextResource = TextResource.StringId( @@ -93,3 +117,9 @@ private fun OTPError.toMessage(): TextResource = TextResource.StringId( Type.CODE_INVALID -> R.string.stripe_verification_codeInvalid } ) + +private fun OTPElement.populateTestCode() { + for (character in "000000") { + controller.onAutofillDigit(character.toString()) + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentPreviewParameterProvider.kt index 9461a7e714a..1e4de0c3b73 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentPreviewParameterProvider.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentPreviewParameterProvider.kt @@ -5,6 +5,7 @@ import androidx.compose.material.ModalBottomSheetValue import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.airbnb.mvrx.Success import com.stripe.android.financialconnections.model.Bullet +import com.stripe.android.financialconnections.model.ConnectedAccessNotice import com.stripe.android.financialconnections.model.ConsentPane import com.stripe.android.financialconnections.model.ConsentPaneBody import com.stripe.android.financialconnections.model.DataAccessNotice @@ -12,45 +13,23 @@ import com.stripe.android.financialconnections.model.DataAccessNoticeBody import com.stripe.android.financialconnections.model.Image import com.stripe.android.financialconnections.model.LegalDetailsBody import com.stripe.android.financialconnections.model.LegalDetailsNotice +import com.stripe.android.financialconnections.model.ServerLink @OptIn(ExperimentalMaterialApi::class) internal class ConsentPreviewParameterProvider : PreviewParameterProvider
> { override val values = sequenceOf( - ModalBottomSheetValue.Hidden to canonical(), - ModalBottomSheetValue.Hidden to withNoLogos(), ModalBottomSheetValue.Hidden to withPlatformLogos(), ModalBottomSheetValue.Hidden to withConnectedAccountLogos(), ModalBottomSheetValue.Hidden to manualEntryPlusMicrodeposits(), ModalBottomSheetValue.Expanded to withDataBottomSheet(), - ModalBottomSheetValue.Expanded to withLegalDetailsBottomSheet() + ModalBottomSheetValue.Expanded to withLegalDetailsBottomSheet(), + ModalBottomSheetValue.Expanded to withDataBottomSheetAndConnectedAccount() ) override val count: Int get() = super.count - private fun canonical() = - ConsentState( - consent = Success( - ConsentState.Payload( - consent = sampleConsent().copy(belowCta = null), - merchantLogos = emptyList(), - shouldShowMerchantLogos = false - ) - ) - ) - - private fun withNoLogos() = - ConsentState( - consent = Success( - ConsentState.Payload( - consent = sampleConsent().copy(belowCta = null), - merchantLogos = emptyList(), - shouldShowMerchantLogos = true - ) - ) - ) - private fun withPlatformLogos() = ConsentState( consent = Success( @@ -84,18 +63,42 @@ internal class ConsentPreviewParameterProvider : consent = Success( ConsentState.Payload( consent = sampleConsent(), - merchantLogos = emptyList(), + merchantLogos = listOf( + "www.logo1.com", + "www.logo2.com" + ), shouldShowMerchantLogos = false ) ) ) private fun withDataBottomSheet() = ConsentState( + currentBottomSheet = ConsentState.BottomSheetContent.DATA, + consent = Success( + ConsentState.Payload( + consent = sampleConsent().copy( + dataAccessNotice = sampleConsent().dataAccessNotice.copy( + connectedAccountNotice = null + ) + ), + merchantLogos = listOf( + "www.logo1.com", + "www.logo2.com" + ), + shouldShowMerchantLogos = false + ) + ) + ) + + private fun withDataBottomSheetAndConnectedAccount() = ConsentState( currentBottomSheet = ConsentState.BottomSheetContent.DATA, consent = Success( ConsentState.Payload( consent = sampleConsent(), - merchantLogos = emptyList(), + merchantLogos = listOf( + "www.logo1.com", + "www.logo2.com" + ), shouldShowMerchantLogos = false ) ) @@ -106,15 +109,17 @@ internal class ConsentPreviewParameterProvider : consent = Success( ConsentState.Payload( consent = sampleConsent().copy(belowCta = null), - merchantLogos = emptyList(), + merchantLogos = listOf( + "www.logo1.com", + "www.logo2.com" + ), shouldShowMerchantLogos = false ) ) ) - @Suppress("LongMethod") private fun sampleConsent(): ConsentPane = ConsentPane( - title = "Goldilocks works with Stripe to link your accounts", + title = "Goldilocks uses Stripe to link your accounts", body = ConsentPaneBody( bullets = listOf( Bullet( @@ -124,12 +129,10 @@ internal class ConsentPreviewParameterProvider : ), Bullet( icon = Image("https://www.cdn.stripe.com/12321312321.png"), - content = "Stripe will allow Goldilocks to access only the data requested", title = "Stripe will allow Goldilocks to access only the data requested" ), Bullet( icon = Image("https://www.cdn.stripe.com/12321312321.png"), - content = "Stripe will allow Goldilocks to access only the data requested", title = "Stripe will allow Goldilocks to access only the data requested" ), ) @@ -139,40 +142,56 @@ internal class ConsentPreviewParameterProvider : " We never share your login details with them.", cta = "Agree", dataAccessNotice = DataAccessNotice( - title = "Goldilocks works with Stripe to link your accounts", + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Goldilocks uses Stripe to link your accounts", subtitle = "Goldilocks will use your account and routing number, balances and transactions:", body = DataAccessNoticeBody( - bullets = listOf( - Bullet( - icon = Image("https://www.cdn.stripe.com/12321312321.png"), - title = "Account details", - content = "Account number, routing number, account type, account nickname." - ), - Bullet( - icon = Image("https://www.cdn.stripe.com/12321312321.png"), - title = "Account details", - content = "Account number, routing number, account type, account nickname." - ), + bullets = bullets() + ), + disclaimer = "Learn more about data access", + connectedAccountNotice = ConnectedAccessNotice( + subtitle = "Connected account placeholder", + body = DataAccessNoticeBody( + bullets = bullets() ) ), - learnMore = "Learn more about data access", - connectedAccountNotice = "Connected account placeholder", cta = "OK" ), legalDetailsNotice = LegalDetailsNotice( - title = "Stripe uses your account data as described in the Terms, including:", + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Terms and privacy policy", + subtitle = "Stripe only uses your data and credentials as described in the Terms, " + + "such as to improve its services, manage loss, and mitigate fraud.", body = LegalDetailsBody( - bullets = listOf( - Bullet( - content = "To improve our services" + links = listOf( + ServerLink( + title = "Terms", ), - Bullet( - content = "To manage fraud and loss risk of transactions" + ServerLink( + title = "Privacy Policy", ), ) ), - learnMore = "Learn more", + disclaimer = "Learn more", cta = "OK" ), ) + + private fun bullets() = listOf( + Bullet( + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Account details", + content = null + ), + Bullet( + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Balances", + content = null + ), + Bullet( + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Transactions", + content = null + ), + ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentScreen.kt index 3edcaa19805..0c0fd4acf63 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentScreen.kt @@ -1,31 +1,16 @@ -@file:OptIn( - ExperimentalMaterialApi::class, - ExperimentalComposeUiApi::class -) -@file:Suppress("LongMethod", "TooManyFunctions") - package com.stripe.android.financialconnections.features.consent import androidx.activity.compose.BackHandler -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ModalBottomSheetLayout import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Text @@ -37,11 +22,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign @@ -55,32 +37,30 @@ import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel -import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.features.common.BulletItem import com.stripe.android.financialconnections.features.common.DataAccessBottomSheetContent import com.stripe.android.financialconnections.features.common.LegalDetailsBottomSheetContent -import com.stripe.android.financialconnections.features.common.LoadingShimmerEffect +import com.stripe.android.financialconnections.features.common.ListItem import com.stripe.android.financialconnections.features.common.UnclassifiedErrorContent import com.stripe.android.financialconnections.features.consent.ConsentState.ViewEffect.OpenBottomSheet import com.stripe.android.financialconnections.features.consent.ConsentState.ViewEffect.OpenUrl +import com.stripe.android.financialconnections.features.consent.ui.ConsentLogoHeader import com.stripe.android.financialconnections.model.ConsentPane import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.presentation.parentViewModel import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview -import com.stripe.android.financialconnections.ui.LocalImageLoader import com.stripe.android.financialconnections.ui.LocalReducedBranding import com.stripe.android.financialconnections.ui.TextResource import com.stripe.android.financialconnections.ui.components.AnnotatedText import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsModalBottomSheetLayout import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar -import com.stripe.android.financialconnections.ui.components.StringAnnotation import com.stripe.android.financialconnections.ui.components.elevation import com.stripe.android.financialconnections.ui.sdui.BulletUI import com.stripe.android.financialconnections.ui.sdui.fromHtml import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography -import com.stripe.android.uicore.image.StripeImage +import com.stripe.android.financialconnections.ui.theme.LazyLayout import kotlinx.coroutines.launch @ExperimentalMaterialApi @@ -169,7 +149,7 @@ private fun ConsentMainContent( onContinueClick: () -> Unit, onCloseClick: () -> Unit ) { - val scrollState = rememberScrollState() + val scrollState = rememberLazyListState() val title = remember(payload.consent.title) { TextResource.Text(fromHtml(payload.consent.title)) } @@ -188,133 +168,58 @@ private fun ConsentMainContent( ) } ) { - Column( - Modifier.fillMaxSize() - ) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(scrollState) - .padding( - top = 0.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) - ) { - if (payload.shouldShowMerchantLogos) { - // Merchant logos: Control - ConsentLogoHeader( - logos = payload.merchantLogos, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - Spacer(modifier = Modifier.size(20.dp)) - AnnotatedText( - text = title, - onClickableTextClick = { onClickableTextClick(it) }, - defaultStyle = typography.subtitle.copy( - textAlign = TextAlign.Center - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.subtitle - .toSpanStyle() - .copy(color = colors.textBrand), - ) - ) - } else { - // Merchant logos: Treatment - Spacer(modifier = Modifier.size(16.dp)) - AnnotatedText( - text = title, - onClickableTextClick = { onClickableTextClick(it) }, - defaultStyle = typography.subtitle, - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.subtitle - .toSpanStyle() - .copy(color = colors.textBrand), - ) - ) - Spacer(modifier = Modifier.size(24.dp)) - } - bullets.forEach { bullet -> - Spacer(modifier = Modifier.size(16.dp)) - BulletItem( - bullet, - onClickableTextClick = onClickableTextClick - ) - } - Spacer(modifier = Modifier.weight(1f)) + LazyLayout( + lazyListState = scrollState, + body = { + consentBody( + payload = payload, + title = title, + onClickableTextClick = onClickableTextClick, + bullets = bullets + ) + }, + footer = { + ConsentFooter( + consent = payload.consent, + acceptConsent = acceptConsent, + onClickableTextClick = onClickableTextClick, + onContinueClick = onContinueClick + ) } - ConsentFooter( - consent = payload.consent, - acceptConsent = acceptConsent, - onClickableTextClick = onClickableTextClick, - onContinueClick = onContinueClick - ) - } + ) } } -@Composable -@Suppress("MagicNumber") -private fun ConsentLogoHeader( - modifier: Modifier = Modifier, - logos: List +private fun LazyListScope.consentBody( + payload: ConsentState.Payload, + title: TextResource.Text, + onClickableTextClick: (String) -> Unit, + bullets: List ) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - /** - * - 2 logos: (platform or institution) - * - 3 logos: (connected account) - * - Other # of logos: Fallback to Stripe logo as client can't render. - */ - if (logos.size != 2 && logos.size != 3) { - Image( - painterResource(id = R.drawable.stripe_logo), - contentDescription = null, - modifier = Modifier - .width(60.dp) - .height(25.dp) - .clip(CircleShape), + item { + Spacer(modifier = Modifier.size(8.dp)) + ConsentLogoHeader( + modifier = Modifier.fillMaxWidth(), + logos = payload.merchantLogos, + ) + Spacer(modifier = Modifier.size(32.dp)) + } + item { + AnnotatedText( + text = title, + onClickableTextClick = { onClickableTextClick(it) }, + defaultStyle = typography.headingXLarge.copy( + textAlign = TextAlign.Center ) - } else { - logos.forEachIndexed { index, logoUrl -> - StripeImage( - url = logoUrl, - debugPainter = painterResource( - id = R.drawable.stripe_ic_brandicon_institution_circle - ), - loadingContent = { - LoadingShimmerEffect { shimmer -> - Spacer( - modifier = Modifier - .align(Alignment.Center) - .size(40.dp) - .clip(RoundedCornerShape(10.dp)) - .fillMaxWidth(fraction = 0.5f) - .background(shimmer) - ) - } - }, - imageLoader = LocalImageLoader.current, - contentScale = ContentScale.Crop, - contentDescription = null, - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - ) - // Adds ellipsis to link logos. - if (index != logos.lastIndex) { - Image( - painterResource(id = R.drawable.stripe_consent_logo_ellipsis), - contentDescription = null - ) - } - } - } + ) + Spacer(modifier = Modifier.size(32.dp)) + } + items(bullets) { bullet -> + ListItem( + bullet = bullet, + onClickableTextClick = onClickableTextClick + ) + Spacer(modifier = Modifier.size(24.dp)) } } @@ -329,11 +234,8 @@ private fun LoadedContent( onConfirmModalClick: () -> Unit, bottomSheetMode: ConsentState.BottomSheetContent?, ) { - ModalBottomSheetLayout( + FinancialConnectionsModalBottomSheetLayout( sheetState = bottomSheetState, - sheetBackgroundColor = colors.backgroundSurface, - sheetShape = RoundedCornerShape(8.dp), - scrimColor = colors.textSecondary.copy(alpha = 0.5f), sheetContent = { when (bottomSheetMode) { ConsentState.BottomSheetContent.LEGAL -> LegalDetailsBottomSheetContent( @@ -363,6 +265,7 @@ private fun LoadedContent( ) } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun ConsentFooter( acceptConsent: Async , @@ -376,28 +279,14 @@ private fun ConsentFooter( val belowCta = remember(consent.belowCta) { consent.belowCta?.let { TextResource.Text(fromHtml(consent.belowCta)) } } - Column( - modifier = Modifier.padding( - start = 24.dp, - end = 24.dp, - top = 16.dp, - bottom = 24.dp - ) - ) { + Column { AnnotatedText( + modifier = Modifier.fillMaxWidth(), text = aboveCta, onClickableTextClick = onClickableTextClick, - defaultStyle = typography.detail.copy( + defaultStyle = typography.labelSmall.copy( textAlign = TextAlign.Center, - color = colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.detailEmphasized - .toSpanStyle() - .copy(color = colors.textBrand), - StringAnnotation.BOLD to typography.detailEmphasized - .toSpanStyle() - .copy(color = colors.textSecondary) + color = colors.textDefault ) ) Spacer(modifier = Modifier.size(16.dp)) @@ -412,25 +301,16 @@ private fun ConsentFooter( Text(text = consent.cta) } if (belowCta != null) { - Spacer(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.size(16.dp)) AnnotatedText( modifier = Modifier.fillMaxWidth(), text = belowCta, onClickableTextClick = onClickableTextClick, - defaultStyle = typography.detail.copy( + defaultStyle = typography.labelSmall.copy( textAlign = TextAlign.Center, - color = colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to typography.detailEmphasized - .toSpanStyle() - .copy(color = colors.textBrand), - StringAnnotation.BOLD to typography.detailEmphasized - .toSpanStyle() - .copy(color = colors.textSecondary) + color = colors.textDefault ) ) - Spacer(modifier = Modifier.size(16.dp)) } } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentViewModel.kt index 07ff5083544..1eac34eb74f 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ConsentViewModel.kt @@ -1,12 +1,10 @@ package com.stripe.android.financialconnections.features.consent -import android.webkit.URLUtil import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.ViewModelContext import com.stripe.android.core.Logger import com.stripe.android.financialconnections.FinancialConnections -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.Click import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.ConsentAgree import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker @@ -19,12 +17,12 @@ import com.stripe.android.financialconnections.features.consent.ConsentState.Vie import com.stripe.android.financialconnections.features.consent.ConsentState.ViewEffect.OpenUrl import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane -import com.stripe.android.financialconnections.navigation.Destination.ManualEntry +import com.stripe.android.financialconnections.navigation.Destination import com.stripe.android.financialconnections.navigation.NavigationManager import com.stripe.android.financialconnections.navigation.destination import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity +import com.stripe.android.financialconnections.ui.HandleClickableUrl import com.stripe.android.financialconnections.utils.Experiment.CONNECTIONS_CONSENT_COMBINED_LOGO -import com.stripe.android.financialconnections.utils.UriUtils import com.stripe.android.financialconnections.utils.experimentAssignment import com.stripe.android.financialconnections.utils.trackExposure import kotlinx.coroutines.launch @@ -37,7 +35,7 @@ internal class ConsentViewModel @Inject constructor( private val getOrFetchSync: GetOrFetchSync, private val navigationManager: NavigationManager, private val eventTracker: FinancialConnectionsAnalyticsTracker, - private val uriUtils: UriUtils, + private val handleClickableUrl: HandleClickableUrl, private val logger: Logger ) : MavericksViewModel (initialState) { @@ -82,45 +80,37 @@ internal class ConsentViewModel @Inject constructor( }.execute { copy(acceptConsent = it) } } - fun onClickableTextClick(uri: String) { - // if clicked uri contains an eventName query param, track click event. - viewModelScope.launch { - uriUtils.getQueryParameter(uri, "eventName")?.let { eventName -> - eventTracker.track(Click(eventName, pane = Pane.CONSENT)) - } - val date = Date() - if (URLUtil.isNetworkUrl(uri)) { - setState { copy(viewEffect = OpenUrl(uri, date.time)) } - } else { - val managedUri = ConsentClickableText.entries - .firstOrNull { uriUtils.compareSchemeAuthorityAndPath(it.value, uri) } - when (managedUri) { - ConsentClickableText.DATA -> { - setState { - copy( - currentBottomSheet = BottomSheetContent.DATA, - viewEffect = ViewEffect.OpenBottomSheet(date.time) - ) - } + fun onClickableTextClick(uri: String) = viewModelScope.launch { + val date = Date() + handleClickableUrl( + currentPane = Pane.CONSENT, + uri = uri, + onNetworkUrlClicked = { setState { copy(viewEffect = OpenUrl(uri, date.time)) } }, + knownDeeplinkActions = mapOf( + // Clicked on the "Data Access" link -> Open the Data Access bottom sheet + ConsentClickableText.DATA.value to { + setState { + copy( + currentBottomSheet = BottomSheetContent.DATA, + viewEffect = ViewEffect.OpenBottomSheet(date.time) + ) } - - ConsentClickableText.MANUAL_ENTRY -> { - navigationManager.tryNavigateTo(ManualEntry(referrer = Pane.CONSENT)) - } - - ConsentClickableText.LEGAL_DETAILS -> { - setState { - copy( - currentBottomSheet = BottomSheetContent.LEGAL, - viewEffect = ViewEffect.OpenBottomSheet(date.time) - ) - } + }, + // Clicked on the "Legal details" link -> Open the Legal Details bottom sheet + ConsentClickableText.LEGAL_DETAILS.value to { + setState { + copy( + viewEffect = ViewEffect.OpenBottomSheet(date.time), + currentBottomSheet = BottomSheetContent.LEGAL + ) } - - null -> logger.error("Unrecognized clickable text: $uri") - } - } - } + }, + // Clicked on the "Manual entry" link -> Navigate to the Manual Entry screen + ConsentClickableText.MANUAL_ENTRY.value to { + navigationManager.tryNavigateTo(Destination.ManualEntry(referrer = Pane.CONSENT)) + }, + ) + ) } fun onViewEffectLaunched() { diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ui/ConsentLogoHeader.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ui/ConsentLogoHeader.kt new file mode 100644 index 00000000000..1d7cb8a3aa3 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/consent/ui/ConsentLogoHeader.kt @@ -0,0 +1,265 @@ +package com.stripe.android.financialconnections.features.consent.ui + +import android.graphics.Bitmap +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.dp +import com.stripe.android.financialconnections.ui.LocalImageLoader +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll + +private val LogoSize = 72.dp +private val DotsContainerHeight = 6.dp +private val DotsContainerWidth = 32.dp + +@Composable +internal fun ConsentLogoHeader( + modifier: Modifier = Modifier, + logos: List +) { + val isPreview = LocalInspectionMode.current + val localDensity = LocalDensity.current + val bitmapLoadSize = remember { with(localDensity) { 36.dp.toPx().toInt() } } + val stripeImageLoader = LocalImageLoader.current + val placeholderBitmap: ImageBitmap = rememberPlaceholderBitmap(bitmapLoadSize, colors.backgroundOffset) + var bitmaps: List by remember { + mutableStateOf( + if (isPreview) { + debugPreviewBitmaps(logos, bitmapLoadSize) + } else { + List(logos.size) { placeholderBitmap } + } + ) + } + + LaunchedEffect(logos) { + bitmaps = logos.map { + async { + stripeImageLoader.load(it, bitmapLoadSize, bitmapLoadSize) + .getOrNull() + ?.asImageBitmap() + ?: placeholderBitmap + } + }.awaitAll() + } + + Box( + modifier = modifier + .height(LogoSize) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + BackgroundRow(images = bitmaps) + ForegroundRow(images = bitmaps) + } +} + +@Composable +private fun BackgroundRow(images: List ) { + Row(verticalAlignment = Alignment.CenterVertically) { + for ((index, image) in images.withIndex()) { + Spacer(modifier = Modifier.width(LogoSize)) + + if (index != images.lastIndex) { + val nextImage = images[index + 1] + AnimatedDotsWithFixedGradient( + startColor = getPrevalentColorCloseToDots( + bitmap = image.asAndroidBitmap(), + startSide = false, + ), + endColor = getPrevalentColorCloseToDots( + bitmap = nextImage.asAndroidBitmap(), + startSide = true, + ) + ) + } + } + } +} + +@Composable +private fun ForegroundRow(images: List ) { + Row(verticalAlignment = Alignment.CenterVertically) { + for ((index, image) in images.withIndex()) { + Logo(image) + if (index != images.lastIndex) { + Spacer(modifier = Modifier.width(DotsContainerWidth)) + } + } + } +} + +private fun debugPreviewBitmaps( + size: List , + bitmapLoadSize: Int, +): List { + return listOf(Color.Red, Color.Blue, Color.Green).take(size.size).map { + val androidBitmap = Bitmap.createBitmap(bitmapLoadSize, bitmapLoadSize, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(androidBitmap) + canvas.drawColor(it.toArgb()) + androidBitmap.asImageBitmap() + } +} + +@Composable +private fun rememberPlaceholderBitmap(bitmapLoadSize: Int, placeholderColor: Color): ImageBitmap = remember { + val androidBitmap = Bitmap.createBitmap( + bitmapLoadSize, + bitmapLoadSize, + Bitmap.Config.ARGB_8888 + ) + androidBitmap.eraseColor(placeholderColor.toArgb()) + androidBitmap.asImageBitmap() +} + +@Composable +private fun AnimatedDotsWithFixedGradient( + modifier: Modifier = Modifier, + startColor: Color, + endColor: Color +) { + val infiniteTransition = rememberInfiniteTransition( + label = "animated-dots-transition" + ) + val animatedOffset by infiniteTransition.animateFloat( + label = "animated-dots", + initialValue = 0f, + targetValue = with(LocalDensity.current) { 10.dp.toPx() }, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + ) + + val gradientBrush = Brush.horizontalGradient( + colors = listOf(startColor, endColor) + ) + + Box( + modifier = modifier + .width(DotsContainerWidth) + .height(DotsContainerHeight) + .background(Color.White) + ) { + Canvas(modifier = Modifier.matchParentSize()) { + val dotRadius = 3.dp.toPx() + val dotSpacing = 10.dp.toPx() + val dotY = center.y + val numberOfDots = (size.width / dotSpacing).toInt() + 2 // Enough dots to fill the screen + 2 extra + + // Create a path for the animated dots + val path = Path().apply { + for (i in -1 until numberOfDots) { // Start with -1 to have an off-screen dot + val x = i * dotSpacing + animatedOffset - dotSpacing // Offset by one dot spacing + addOval(Rect(Offset(x, dotY - dotRadius), Size(dotRadius * 2, dotRadius * 2))) + } + } + + // Clip the canvas to the path of the dots + clipPath(path) { + // Draw the gradient within the clipped area (where the dots are) + drawRect( + brush = gradientBrush, + size = Size(size.width, dotRadius * 2), + topLeft = Offset(0f, dotY - dotRadius) + ) + } + } + } +} + +@Composable +private fun Logo(imageBitmap: ImageBitmap) { + val shape = RoundedCornerShape(18.dp) + Box( + modifier = Modifier + .size(LogoSize) + .shadow(8.dp, shape) + .clip(shape) + .background(color = colors.backgroundOffset, shape = shape) + ) { + Crossfade( + targetState = imageBitmap, + animationSpec = tween(durationMillis = 300) + ) { image -> + Image( + bitmap = image, + contentScale = ContentScale.Crop, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +/** + * Get the prevalent color of the bitmap in the given quarter to generate the gradient for the dots. + * + * It focuses on the horizontal center and either on the 1st or 4th vertical quarter of the + * bitmap, depending on the [startSide] parameter, narrowing the main color search to the area of the logo + * the dots will be merging with. + * + * @param startSide whether the quarter is the start or end of the bitmap + */ +private fun getPrevalentColorCloseToDots(bitmap: Bitmap, startSide: Boolean): Color { + val colorMap = HashMap () + val width = bitmap.width + val height = bitmap.height + val startX = if (startSide) 0 else (width * 3) / 4 + val endX = if (startSide) width / 4 else width + val startY = height * 2 / 5 // 40% of the height + val endY = height * 3 / 5 // 60% of the height + + for (x in startX until endX) { + for (y in startY until endY) { + val pixelColor = bitmap.getPixel(x, y) + colorMap[pixelColor] = (colorMap[pixelColor] ?: 0) + 1 + } + } + return colorMap + .maxByOrNull { it.value } + ?.let { Color(it.key) } + ?: Color.Black // Default color if no prevalent color is found +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorPreviewParameterProvider.kt new file mode 100644 index 00000000000..281d1ce491f --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorPreviewParameterProvider.kt @@ -0,0 +1,74 @@ +package com.stripe.android.financialconnections.features.error + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.stripe.android.core.exception.APIException +import com.stripe.android.financialconnections.exception.InstitutionPlannedDowntimeError +import com.stripe.android.financialconnections.exception.InstitutionUnplannedDowntimeError +import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution + +internal class ErrorPreviewParameterProvider : + PreviewParameterProvider { + override val values = sequenceOf( + loading(), + unclassified(), + expectedDowntime(), + unexpectedDowntime() + ) + + private fun loading() = ErrorState( + payload = Loading(), + ) + + private fun unclassified() = ErrorState( + payload = Success( + ErrorState.Payload( + error = IllegalArgumentException("An unknown error occurred."), + allowManualEntry = true, + disableLinkMoreAccounts = true, + ) + ), + ) + + private fun expectedDowntime() = ErrorState( + payload = Success( + ErrorState.Payload( + error = InstitutionPlannedDowntimeError( + institution = institution(), + showManualEntry = true, + isToday = true, + backUpAt = 10000L, + stripeException = APIException() + ), + allowManualEntry = true, + disableLinkMoreAccounts = true, + ) + ), + ) + + private fun unexpectedDowntime() = ErrorState( + payload = Success( + ErrorState.Payload( + error = InstitutionUnplannedDowntimeError( + institution = institution(), + showManualEntry = true, + stripeException = APIException() + ), + allowManualEntry = true, + disableLinkMoreAccounts = true, + ) + ), + ) + + private fun institution() = FinancialConnectionsInstitution( + id = "3", + name = "Random Institution", + url = "Random Institution url", + featured = false, + featuredOrder = null, + icon = null, + logo = null, + mobileHandoffCapable = false + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorScreen.kt new file mode 100644 index 00000000000..a7311756308 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorScreen.kt @@ -0,0 +1,160 @@ +package com.stripe.android.financialconnections.features.error + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.compose.collectAsState +import com.airbnb.mvrx.compose.mavericksViewModel +import com.stripe.android.financialconnections.exception.InstitutionPlannedDowntimeError +import com.stripe.android.financialconnections.exception.InstitutionUnplannedDowntimeError +import com.stripe.android.financialconnections.exception.PartnerAuthError +import com.stripe.android.financialconnections.features.common.FullScreenGenericLoading +import com.stripe.android.financialconnections.features.common.InstitutionPlannedDowntimeErrorContent +import com.stripe.android.financialconnections.features.common.InstitutionUnknownErrorContent +import com.stripe.android.financialconnections.features.common.InstitutionUnplannedDowntimeErrorContent +import com.stripe.android.financialconnections.features.common.UnclassifiedErrorContent +import com.stripe.android.financialconnections.presentation.parentViewModel +import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar + +@Composable +internal fun ErrorScreen() { + val viewModel: ErrorViewModel = mavericksViewModel() + val parentViewModel = parentViewModel() + BackHandler(true) { } + val payload = viewModel.collectAsState { it.payload } + ErrorContent( + payload = payload.value, + onManualEntryClick = viewModel::onManualEntryClick, + onSelectBankClick = viewModel::onSelectAnotherBank, + onCloseFromErrorClick = parentViewModel::onCloseFromErrorClick + ) +} + +@Composable +private fun ErrorContent( + payload: Async , + onSelectBankClick: () -> Unit, + onManualEntryClick: () -> Unit, + onCloseFromErrorClick: (Throwable) -> Unit +) { + when (payload) { + Uninitialized, + is Loading -> FullScreenError( + showBack = false, + onCloseClick = { }, + content = { FullScreenGenericLoading() } + ) + + // Render error successfully retrieved from a previous pane + is Success -> ErrorContent( + payload().error, + onSelectAnotherBank = onSelectBankClick, + onEnterDetailsManually = onManualEntryClick, + onCloseFromErrorClick = onCloseFromErrorClick + ) + + // Something wrong happened while trying to retrieve the error, render the unclassified error + is Fail -> ErrorContent( + payload.error, + onSelectAnotherBank = onSelectBankClick, + onEnterDetailsManually = onManualEntryClick, + onCloseFromErrorClick = onCloseFromErrorClick + ) + } +} + +@Composable +private fun ErrorContent( + error: Throwable, + onSelectAnotherBank: () -> Unit, + onEnterDetailsManually: () -> Unit, + onCloseFromErrorClick: (Throwable) -> Unit +) { + when (error) { + is InstitutionPlannedDowntimeError -> FullScreenError( + showBack = false, + onCloseClick = { onCloseFromErrorClick(error) }, + content = { + InstitutionPlannedDowntimeErrorContent( + exception = error, + onSelectAnotherBank = onSelectAnotherBank, + onEnterDetailsManually = onEnterDetailsManually + ) + } + ) + + is InstitutionUnplannedDowntimeError -> FullScreenError( + showBack = false, + onCloseClick = { onCloseFromErrorClick(error) }, + content = { + InstitutionUnplannedDowntimeErrorContent( + exception = error, + onSelectAnotherBank = onSelectAnotherBank, + onEnterDetailsManually = onEnterDetailsManually + ) + } + ) + + is PartnerAuthError -> FullScreenError( + showBack = false, + onCloseClick = { onCloseFromErrorClick(error) }, + content = { + InstitutionUnknownErrorContent( + onSelectAnotherBank = onSelectAnotherBank, + ) + } + ) + + else -> FullScreenError( + showBack = false, + onCloseClick = { onCloseFromErrorClick(error) }, + content = { + UnclassifiedErrorContent( + error = error, + onCloseFromErrorClick = onCloseFromErrorClick + ) + } + ) + } +} + +@Composable +private fun FullScreenError( + showBack: Boolean, + onCloseClick: () -> Unit, + content: @Composable () -> Unit +) { + FinancialConnectionsScaffold( + topBar = { + FinancialConnectionsTopAppBar( + allowBackNavigation = showBack, + onCloseClick = onCloseClick + ) + } + ) { + content() + } +} + +@Preview +@Composable +internal fun ErrorScreenPreview( + @PreviewParameter(ErrorPreviewParameterProvider::class) state: ErrorState +) { + FinancialConnectionsPreview { + ErrorContent( + payload = state.payload, + onSelectBankClick = {}, + onManualEntryClick = {}, + onCloseFromErrorClick = {} + ) + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorSubcomponent.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorSubcomponent.kt new file mode 100644 index 00000000000..43f6a2e7c07 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorSubcomponent.kt @@ -0,0 +1,15 @@ +package com.stripe.android.financialconnections.features.error + +import dagger.BindsInstance +import dagger.Subcomponent + +@Subcomponent +internal interface ErrorSubcomponent { + + val viewModel: ErrorViewModel + + @Subcomponent.Factory + interface Factory { + fun create(@BindsInstance initialState: ErrorState): ErrorSubcomponent + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorViewModel.kt new file mode 100644 index 00000000000..a84e837f654 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/error/ErrorViewModel.kt @@ -0,0 +1,128 @@ +package com.stripe.android.financialconnections.features.error + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.MavericksViewModel +import com.airbnb.mvrx.MavericksViewModelFactory +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.ViewModelContext +import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.analytics.logError +import com.stripe.android.financialconnections.domain.GetManifest +import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator +import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator.Message +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane +import com.stripe.android.financialconnections.navigation.Destination +import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.navigation.PopUpToBehavior +import com.stripe.android.financialconnections.repository.FinancialConnectionsErrorRepository +import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal class ErrorViewModel @Inject constructor( + initialState: ErrorState, + private val coordinator: NativeAuthFlowCoordinator, + private val getManifest: GetManifest, + private val errorRepository: FinancialConnectionsErrorRepository, + private val eventTracker: FinancialConnectionsAnalyticsTracker, + private val navigationManager: NavigationManager, + private val logger: Logger +) : MavericksViewModel (initialState) { + + init { + logErrors() + suspend { + // Clear the partner web auth state if it exists, so that if the user lands back in the partner_auth + // pane after an error, they will be able to start over. + coordinator().emit(Message.ClearPartnerWebAuth) + ErrorState.Payload( + error = requireNotNull(errorRepository.get()), + disableLinkMoreAccounts = getManifest().disableLinkMoreAccounts, + allowManualEntry = getManifest().allowManualEntry + ) + }.execute { copy(payload = it) } + } + + private fun logErrors() { + onAsync( + ErrorState::payload, + onFail = { error -> + eventTracker.logError( + extraMessage = "Error linking more accounts", + error = error, + logger = logger, + pane = PANE + ) + }, + ) + } + + fun onManualEntryClick() { + // NOTE: we do not clear error when going to manual entry + // this allows us to enable the user to go back to this pane. + // we may still attach `terminal_error` in the /complete call, + // but we do that today in v2 and we will still successfully complete + // the session. + navigationManager.tryNavigateTo( + route = Destination.ManualEntry(referrer = PANE), + ) + } + + private fun reset() { + navigationManager.tryNavigateTo( + route = Destination.Reset(referrer = PANE), + popUpTo = PopUpToBehavior.Current(inclusive = true), + ) + } + + suspend fun close(error: Throwable) { + coordinator().emit(Message.CloseWithError(error)) + } + + fun onSelectAnotherBank() = viewModelScope.launch { + kotlin.runCatching { + val payload = requireNotNull(awaitState().payload()) + if (payload.disableLinkMoreAccounts) { + close(payload.error) + } else { + reset() + } + }.onFailure { + close(it) + } + } + + override fun onCleared() { + errorRepository.clear() + super.onCleared() + } + + companion object : MavericksViewModelFactory { + + override fun create( + viewModelContext: ViewModelContext, + state: ErrorState + ): ErrorViewModel { + return viewModelContext.activity () + .viewModel + .activityRetainedComponent + .errorSubcomponent + .create(state) + .viewModel + } + + internal val PANE = Pane.UNEXPECTED_ERROR + } +} + +internal data class ErrorState( + val payload: Async = Uninitialized +) : MavericksState { + data class Payload( + val error: Throwable, + val disableLinkMoreAccounts: Boolean, + val allowManualEntry: Boolean + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/exit/ExitModal.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/exit/ExitModal.kt new file mode 100644 index 00000000000..1151bee82c8 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/exit/ExitModal.kt @@ -0,0 +1,101 @@ +package com.stripe.android.financialconnections.features.exit + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavBackStackEntry +import com.airbnb.mvrx.compose.collectAsState +import com.airbnb.mvrx.compose.mavericksViewModel +import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.features.common.ShapedIcon +import com.stripe.android.financialconnections.ui.TextResource +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography + +@Composable +internal fun ExitModal( + backStackEntry: NavBackStackEntry +) { + val viewModel: ExitViewModel = mavericksViewModel(argsFactory = { backStackEntry.arguments }) + + val state by viewModel.collectAsState() + state.payload()?.let { + ExitModalContent( + description = it.description, + loading = state.closing, + onExit = viewModel::onCloseConfirm, + onCancel = viewModel::onCloseDismiss + ) + } +} + +@Composable +private fun ExitModalContent( + description: TextResource, + loading: Boolean, + onExit: () -> Unit, + onCancel: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) { + ShapedIcon( + painter = painterResource(id = R.drawable.stripe_ic_panel_arrow_right), + contentDescription = stringResource(R.string.stripe_exit_modal_title) + ) + Spacer(modifier = Modifier.size(16.dp)) + Text( + text = stringResource(R.string.stripe_exit_modal_title), + style = typography.headingMedium, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(text = description.toText().toString()) + Spacer(modifier = Modifier.size(24.dp)) + FinancialConnectionsButton( + modifier = Modifier.fillMaxWidth(), + loading = loading, + onClick = onExit + ) { + Text(text = stringResource(id = R.string.stripe_exit_modal_cta_accept)) + } + Spacer(modifier = Modifier.size(8.dp)) + FinancialConnectionsButton( + modifier = Modifier.fillMaxWidth(), + enabled = !loading, + type = FinancialConnectionsButton.Type.Secondary, + onClick = onCancel + ) { + Text(text = stringResource(id = R.string.stripe_exit_modal_cta_cancel)) + } + } +} + +@Composable +@Preview +internal fun ExitModalPreview() { + FinancialConnectionsTheme { + Surface(color = colors.backgroundSurface) { + ExitModalContent( + description = TextResource.StringId(R.string.stripe_exit_modal_desc, listOf("MerchantName")), + loading = false, + onExit = {}, + onCancel = {} + ) + } + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/exit/ExitSubcomponent.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/exit/ExitSubcomponent.kt new file mode 100644 index 00000000000..b48d7c96424 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/exit/ExitSubcomponent.kt @@ -0,0 +1,15 @@ +package com.stripe.android.financialconnections.features.exit + +import dagger.BindsInstance +import dagger.Subcomponent + +@Subcomponent +internal interface ExitSubcomponent { + + val viewModel: ExitViewModel + + @Subcomponent.Factory + interface Factory { + fun create(@BindsInstance initialState: ExitState): ExitSubcomponent + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/exit/ExitViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/exit/ExitViewModel.kt new file mode 100644 index 00000000000..37e98ec4c34 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/exit/ExitViewModel.kt @@ -0,0 +1,121 @@ +package com.stripe.android.financialconnections.features.exit + +import android.os.Bundle +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.MavericksViewModel +import com.airbnb.mvrx.MavericksViewModelFactory +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.ViewModelContext +import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.analytics.logError +import com.stripe.android.financialconnections.domain.GetManifest +import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator +import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator.Message +import com.stripe.android.financialconnections.features.common.getBusinessName +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane +import com.stripe.android.financialconnections.navigation.Destination +import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity +import com.stripe.android.financialconnections.ui.TextResource +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal class ExitViewModel @Inject constructor( + initialState: ExitState, + private val getManifest: GetManifest, + private val coordinator: NativeAuthFlowCoordinator, + private val eventTracker: FinancialConnectionsAnalyticsTracker, + private val navigationManager: NavigationManager, + private val logger: Logger +) : MavericksViewModel (initialState) { + + init { + logErrors() + suspend { + val manifest = kotlin.runCatching { getManifest() }.getOrNull() + val businessName = manifest?.getBusinessName() + val isNetworkingSignupPane = + manifest?.isNetworkingUserFlow == true && awaitState().referrer == Pane.NETWORKING_LINK_SIGNUP_PANE + val description = when { + isNetworkingSignupPane -> when (businessName) { + null -> TextResource.StringId(R.string.stripe_close_dialog_networking_desc_no_business) + else -> TextResource.StringId( + value = R.string.stripe_close_dialog_networking_desc, + args = listOf(businessName) + ) + } + + else -> when (businessName) { + null -> TextResource.StringId(R.string.stripe_exit_modal_desc_no_business) + else -> TextResource.StringId( + value = R.string.stripe_exit_modal_desc, + args = listOf(businessName) + ) + } + } + ExitState.Payload( + description = description, + ) + }.execute { copy(payload = it) } + } + + fun onCloseConfirm() = viewModelScope.launch { + setState { copy(closing = true) } + coordinator().emit(Message.Complete(cause = null)) + } + + fun onCloseDismiss() { + navigationManager.tryNavigateBack() + } + + private fun logErrors() { + onAsync( + ExitState::payload, + onFail = { error -> + eventTracker.logError( + extraMessage = "Error loading payload", + error = error, + logger = logger, + pane = PANE + ) + }, + ) + } + + companion object : MavericksViewModelFactory { + + override fun create( + viewModelContext: ViewModelContext, + state: ExitState + ): ExitViewModel { + return viewModelContext.activity () + .viewModel + .activityRetainedComponent + .exitSubcomponent + .create(state) + .viewModel + } + + internal val PANE = Pane.EXIT + } +} + +internal data class ExitState( + val referrer: Pane?, + val payload: Async , + val closing: Boolean +) : MavericksState { + data class Payload( + val description: TextResource, + ) + + @Suppress("unused") // used by mavericks to create initial state. + constructor(args: Bundle?) : this( + referrer = Destination.referrer(args), + payload = Uninitialized, + closing = false + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerPreviewParameterProvider.kt index 7f805a87ace..04cbaa75798 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerPreviewParameterProvider.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerPreviewParameterProvider.kt @@ -9,132 +9,183 @@ import com.stripe.android.financialconnections.model.FinancialConnectionsInstitu import com.stripe.android.financialconnections.model.InstitutionResponse internal class InstitutionPickerPreviewParameterProvider : - PreviewParameterProvider { + PreviewParameterProvider { + override val values = sequenceOf( initialLoading(), - searchModeSearchingInstitutions(), - searchModeWithResults(), - searchModeWithResultsNoManualEntry(), - searchModeNoResults(), - searchModeNoResultsNoManualEntry(), - searchModeFailed(), - searchModeFailedNoManualEntry(), - noSearchMode() + featured(), + searchInProgress(), + searchSuccess(), + searchSuccessNoManualEntry(), + searchNoResults(), + searchNoResultsNoManualEntry(), + searchFailed(), + searchFailedNoManualEntry(), + selectedInstitution(), + partiallyScrolled() ) - private fun initialLoading() = InstitutionPickerState( - previewText = null, - payload = Loading(), - searchInstitutions = Uninitialized, - searchMode = false, + private fun initialLoading() = InstitutionPreviewState( + state = InstitutionPickerState( + previewText = null, + payload = Loading(), + searchInstitutions = Uninitialized, + ), + initialScroll = 0 + ) + + private fun featured() = InstitutionPreviewState( + state = InstitutionPickerState( + previewText = null, + payload = Success(payload()), + searchInstitutions = Uninitialized, + ), + initialScroll = 0 ) - private fun searchModeSearchingInstitutions() = InstitutionPickerState( - previewText = "Some query", - payload = Success(payload()), - searchInstitutions = Loading(), - searchMode = true, + private fun searchInProgress() = InstitutionPreviewState( + state = InstitutionPickerState( + previewText = "Some query", + payload = Success(payload()), + searchInstitutions = Loading(), + ), + initialScroll = 0 ) - private fun searchModeWithResults() = InstitutionPickerState( - previewText = "Some query", - payload = Success(payload()), - searchInstitutions = Success(institutionResponse().copy(showManualEntry = true)), - searchMode = true, + private fun searchSuccess() = InstitutionPreviewState( + state = InstitutionPickerState( + previewText = "Some query", + payload = Success(payload()), + searchInstitutions = Success(institutionResponse(FEW_INSTITUTIONS).copy(showManualEntry = true)), + ), + initialScroll = 0 ) - private fun searchModeWithResultsNoManualEntry() = InstitutionPickerState( - previewText = "Some query", - payload = Success(payload()), - searchInstitutions = Success(institutionResponse().copy(showManualEntry = false)), - searchMode = true, + private fun searchSuccessNoManualEntry() = InstitutionPreviewState( + state = InstitutionPickerState( + previewText = "Some query", + payload = Success(payload()), + searchInstitutions = Success(institutionResponse(FEW_INSTITUTIONS).copy(showManualEntry = false)), + ), + initialScroll = 0 ) - private fun searchModeNoResults() = InstitutionPickerState( - previewText = "Some query", - payload = Success(payload()), - searchInstitutions = Success( - InstitutionResponse( - data = emptyList(), - showManualEntry = true - ) + private fun searchNoResults() = InstitutionPreviewState( + state = InstitutionPickerState( + previewText = "Some query", + payload = Success(payload()), + searchInstitutions = Success( + InstitutionResponse( + data = emptyList(), + showManualEntry = true + ) + ), ), - searchMode = true, + initialScroll = 0 ) - private fun searchModeNoResultsNoManualEntry() = InstitutionPickerState( - previewText = "Some query", - payload = Success(payload()), - searchInstitutions = Success( - InstitutionResponse( - data = emptyList(), - showManualEntry = false - ) + private fun searchNoResultsNoManualEntry() = InstitutionPreviewState( + state = InstitutionPickerState( + previewText = "Some query", + payload = Success(payload()), + searchInstitutions = Success( + InstitutionResponse( + data = emptyList(), + showManualEntry = false + ) + ), ), - searchMode = true, + initialScroll = 0 ) - private fun searchModeFailed() = InstitutionPickerState( - previewText = "Some query", - payload = Success(payload().copy(allowManualEntry = true)), - searchInstitutions = Fail(java.lang.Exception("Something went wrong")), - searchMode = true, + private fun searchFailed() = InstitutionPreviewState( + state = InstitutionPickerState( + previewText = "Some query", + payload = Success(payload(manualEntry = true)), + searchInstitutions = Fail(java.lang.Exception("Something went wrong")), + ), + initialScroll = 0 ) - private fun searchModeFailedNoManualEntry() = InstitutionPickerState( - previewText = "Some query", - payload = Success(payload().copy(allowManualEntry = false)), - searchInstitutions = Fail(java.lang.Exception("Something went wrong")), - searchMode = true, + private fun searchFailedNoManualEntry() = InstitutionPreviewState( + state = InstitutionPickerState( + previewText = "Some query", + payload = Success(payload(manualEntry = false)), + searchInstitutions = Fail(java.lang.Exception("Something went wrong")), + ), + initialScroll = 0 ) - private fun noSearchMode() = InstitutionPickerState( - previewText = "Some query", - payload = Success(payload()), - searchInstitutions = Success(institutionResponse()), - searchMode = false, + private fun selectedInstitution() = InstitutionPreviewState( + state = InstitutionPickerState( + previewText = "Some query", + payload = Success(payload()), + searchInstitutions = Success(institutionResponse(FEW_INSTITUTIONS)), + selectedInstitutionId = "2", + createSessionForInstitution = Loading(), + ), + initialScroll = 0 ) - private fun payload() = InstitutionPickerState.Payload( - featuredInstitutions = institutionResponse().data, - allowManualEntry = false, + private fun partiallyScrolled(): InstitutionPreviewState { + return InstitutionPreviewState( + state = InstitutionPickerState( + previewText = "Some query", + payload = Success(payload()), + searchInstitutions = Success(institutionResponse(MANY_INSTITUTIONS)), + ), + initialScroll = 1000 + ) + } + + private fun payload(manualEntry: Boolean = true) = InstitutionPickerState.Payload( + featuredInstitutions = institutionResponse(institutions = FEW_INSTITUTIONS).copy(showManualEntry = manualEntry), searchDisabled = false, featuredInstitutionsDuration = 0 ) - private fun institutionResponse() = InstitutionResponse( + @Suppress("MagicNumber") + private fun institutionResponse(institutions: Int) = InstitutionResponse( showManualEntry = true, listOf( - FinancialConnectionsInstitution( - id = "1", - name = "Very very long institution 1", - url = "institution 1 url", - featured = false, - featuredOrder = null, - icon = null, - logo = null, - mobileHandoffCapable = false + institution(1).copy( + name = "Very very long institution content does not fit - 1", + url = "https://www.institutionUrl.com/1", ), - FinancialConnectionsInstitution( - id = "2", - name = "Institution 2", - url = "Institution 2 url", - featured = false, - featuredOrder = null, - icon = null, - logo = null, - mobileHandoffCapable = false + institution(2), + institution(3).copy( + url = "Unparseable URL" ), - FinancialConnectionsInstitution( - id = "3", - name = "Institution 3", - url = "Institution 3 url", - featured = false, - featuredOrder = null, - icon = null, - logo = null, - mobileHandoffCapable = false - ) + institution(4), + institution(5), + institution(6), + institution(7), + institution(8), + institution(9), + institution(10) + ).take(institutions) + ) + + private fun institution(i: Int): FinancialConnectionsInstitution { + return FinancialConnectionsInstitution( + id = i.toString(), + name = "Institution $i", + url = "otherUrl.com", + featured = false, + featuredOrder = null, + icon = null, + logo = null, + mobileHandoffCapable = false ) + } + + data class InstitutionPreviewState( + val state: InstitutionPickerState, + val initialScroll: Int ) + + companion object { + const val FEW_INSTITUTIONS = 3 + const val MANY_INSTITUTIONS = 10 + } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerScreen.kt index c9b177a0536..6f9191f6501 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerScreen.kt @@ -1,17 +1,13 @@ -@file:Suppress("TooManyFunctions", "LongMethod") - package com.stripe.android.financialconnections.features.institutionpicker -import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -20,42 +16,42 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material.icons.filled.Clear import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -69,314 +65,407 @@ import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.features.common.InstitutionPlaceholder +import com.stripe.android.financialconnections.features.common.FullScreenGenericLoading +import com.stripe.android.financialconnections.features.common.InstitutionIcon import com.stripe.android.financialconnections.features.common.LoadingShimmerEffect import com.stripe.android.financialconnections.features.common.LoadingSpinner +import com.stripe.android.financialconnections.features.common.ShapedIcon import com.stripe.android.financialconnections.features.institutionpicker.InstitutionPickerState.Payload import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.InstitutionResponse import com.stripe.android.financialconnections.presentation.parentViewModel import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview -import com.stripe.android.financialconnections.ui.LocalImageLoader +import com.stripe.android.financialconnections.ui.TextResource +import com.stripe.android.financialconnections.ui.components.AnnotatedText import com.stripe.android.financialconnections.ui.components.FinancialConnectionsOutlinedTextField import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar -import com.stripe.android.financialconnections.ui.components.clickableSingle -import com.stripe.android.financialconnections.ui.theme.Brand100 -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme -import com.stripe.android.uicore.image.StripeImage +import com.stripe.android.financialconnections.ui.components.StringAnnotation +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsColors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography +import kotlinx.coroutines.launch @Composable internal fun InstitutionPickerScreen() { val viewModel: InstitutionPickerViewModel = mavericksViewModel() val parentViewModel = parentViewModel() val state: InstitutionPickerState by viewModel.collectAsState() - - // when in search mode, back closes search. - val focusManager = LocalFocusManager.current - BackHandler(state.searchMode) { - focusManager.clearFocus() - viewModel.onCancelSearchClick() - } + val listState = rememberLazyListState() InstitutionPickerContent( + listState = listState, payload = state.payload, institutions = state.searchInstitutions, - searchMode = state.searchMode, // This is just used to provide a text in Compose previews previewText = state.previewText, + selectedInstitutionId = state.selectedInstitutionId, onQueryChanged = viewModel::onQueryChanged, onInstitutionSelected = viewModel::onInstitutionSelected, - onCancelSearchClick = viewModel::onCancelSearchClick, onCloseClick = { parentViewModel.onCloseWithConfirmationClick(Pane.INSTITUTION_PICKER) }, - onSearchFocused = viewModel::onSearchFocused, onManualEntryClick = viewModel::onManualEntryClick, - onScrollChanged = viewModel::onScrollChanged + onScrollChanged = viewModel::onScrollChanged, ) } @Composable private fun InstitutionPickerContent( + listState: LazyListState, payload: Async , institutions: Async , - searchMode: Boolean, previewText: String?, + selectedInstitutionId: String?, onQueryChanged: (String) -> Unit, onInstitutionSelected: (FinancialConnectionsInstitution, Boolean) -> Unit, - onCancelSearchClick: () -> Unit, onCloseClick: () -> Unit, - onSearchFocused: () -> Unit, onManualEntryClick: () -> Unit, onScrollChanged: () -> Unit ) { FinancialConnectionsScaffold( topBar = { - if (!searchMode) { - FinancialConnectionsTopAppBar( - onCloseClick = onCloseClick - ) - } + FinancialConnectionsTopAppBar( + onCloseClick = onCloseClick + ) } ) { - LoadedContent( - searchMode = searchMode, - previewText = previewText, - onQueryChanged = onQueryChanged, - onSearchFocused = onSearchFocused, - onCancelSearchClick = onCancelSearchClick, - institutions = institutions, - onInstitutionSelected = onInstitutionSelected, - payload = payload, - onManualEntryClick = onManualEntryClick, - onScrollChanged = onScrollChanged - ) + when (payload) { + is Uninitialized, + is Loading, + is Fail -> FullScreenGenericLoading() + + is Success -> LoadedContent( + listState = listState, + previewText = previewText, + selectedInstitutionId = selectedInstitutionId, + onQueryChanged = onQueryChanged, + institutions = institutions, + onInstitutionSelected = onInstitutionSelected, + payload = payload(), + onManualEntryClick = onManualEntryClick, + onScrollChanged = onScrollChanged + ) + } } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun LoadedContent( - searchMode: Boolean, + listState: LazyListState, previewText: String?, + selectedInstitutionId: String?, onQueryChanged: (String) -> Unit, - onSearchFocused: () -> Unit, - onCancelSearchClick: () -> Unit, institutions: Async , onInstitutionSelected: (FinancialConnectionsInstitution, Boolean) -> Unit, - payload: Async , + payload: Payload, onManualEntryClick: () -> Unit, onScrollChanged: () -> Unit, ) { - var input by remember { mutableStateOf(TextFieldValue(previewText ?: "")) } - LaunchedEffect(searchMode) { if (!searchMode) input = TextFieldValue() } - Column { - if (searchMode.not()) { - Spacer(Modifier.size(16.dp)) - Text( - modifier = Modifier - .padding(horizontal = 24.dp) - .fillMaxWidth(), - text = stringResource(R.string.stripe_institutionpicker_pane_select_bank), - style = FinancialConnectionsTheme.typography.subtitle - ) + var input by remember { mutableStateOf(previewText ?: "") } + var shouldEmitScrollEvent by remember { mutableStateOf(true) } + val searchInputFocusRequester = remember { FocusRequester() } + val coroutineScope = rememberCoroutineScope() + + // Scroll event should be emitted just once per search + LaunchedEffect(institutions) { shouldEmitScrollEvent = true } + // Trigger onScrollChanged with the list of institutions when scrolling stops (true -> false) + LaunchedEffect(listState.isScrollInProgress) { + if (institutions()?.data?.isNotEmpty() == true && + !listState.isScrollInProgress && + shouldEmitScrollEvent + ) { + onScrollChanged() + shouldEmitScrollEvent = false } - Spacer(modifier = Modifier.size(16.dp)) - if (payload()?.searchDisabled == false) { - FinancialConnectionsSearchRow( - query = input, - searchMode = searchMode, - onQueryChanged = { - input = it - onQueryChanged(input.text) - }, - onSearchFocused = onSearchFocused, - onCancelSearchClick = onCancelSearchClick + } + + CompositionLocalProvider( + // Disable overscroll as it does not play well with sticky headers. + LocalOverscrollConfiguration provides null + ) { + LazyColumn( + Modifier.padding(horizontal = 16.dp), + state = listState, + content = { + item { SearchTitle(modifier = Modifier.padding(horizontal = 8.dp)) } + item { Spacer(modifier = Modifier.height(24.dp)) } + stickyHeader(key = "searchRow") { + SearchRow( + focusRequester = searchInputFocusRequester, + query = input, + onQueryChanged = { + input = it + onQueryChanged(input) + }, + ) + } + item { Spacer(modifier = Modifier.height(8.dp)) } + + searchResults( + isInputEmpty = input.isBlank(), + payload = payload, + selectedInstitutionId = selectedInstitutionId, + onInstitutionSelected = onInstitutionSelected, + institutions = institutions, + onManualEntryClick = onManualEntryClick, + onSearchMoreClick = { + // Scroll to the top of the list and focus on the search input + coroutineScope.launch { listState.animateScrollToItem(index = 1) } + searchInputFocusRequester.requestFocus() + } + ) + } + ) + } +} + +private fun LazyListScope.searchResults( + isInputEmpty: Boolean, + payload: Payload, + selectedInstitutionId: String?, + onInstitutionSelected: (FinancialConnectionsInstitution, Boolean) -> Unit, + institutions: Async , + onManualEntryClick: () -> Unit, + onSearchMoreClick: () -> Unit +) { + when { + // No input: Display featured institutions. + isInputEmpty -> { + itemsIndexed( + items = payload.featuredInstitutions.data, + key = { _, institution -> institution.id }, + itemContent = { index, institution -> + InstitutionResultTile( + modifier = Modifier.padding(8.dp), + loading = selectedInstitutionId == institution.id, + enabled = selectedInstitutionId?.let { it == institution.id } ?: true, + institution = institution, + index = index, + onInstitutionSelected = { onInstitutionSelected(it, true) } + ) + } ) + item(key = "search_more") { + SearchMoreRow( + modifier = Modifier.padding(8.dp), + onClick = onSearchMoreClick, + enabled = selectedInstitutionId == null, + ) + } } - if (input.text.isNotBlank()) { - SearchInstitutionsList( - institutions = institutions, - onInstitutionSelected = onInstitutionSelected, - onManualEntryClick = onManualEntryClick, - onScrollChanged = onScrollChanged, - allowManualEntry = payload()?.allowManualEntry ?: false - ) - } else { - FeaturedInstitutionsGrid( - modifier = Modifier.weight(1f), - payload = payload, - onInstitutionSelected = onInstitutionSelected - ) + + else -> when (institutions) { + // Load failure: Display error message. + is Fail -> item { + NoResultsTile( + modifier = Modifier.padding(8.dp), + showManualEntry = payload.featuredInstitutions.showManualEntry, + onManualEntryClick = onManualEntryClick + ) + } + + // Loading: Display shimmer. + is Uninitialized, + is Loading -> items((0..10).toList()) { + InstitutionResultShimmer( + modifier = Modifier.padding(8.dp) + ) + } + + // Success: Display search results. + is Success -> if (institutions().data.isEmpty()) { + // NO RESULTS CASE + item { + NoResultsTile( + modifier = Modifier.padding(8.dp), + showManualEntry = institutions().showManualEntry, + onManualEntryClick = onManualEntryClick + ) + } + } else { + // RESULTS CASE: Institution List + Manual Entry final row if needed. + itemsIndexed( + items = institutions().data, + key = { _, institution -> institution.id }, + itemContent = { index, institution -> + InstitutionResultTile( + modifier = Modifier.padding(8.dp), + loading = selectedInstitutionId == institution.id, + enabled = selectedInstitutionId?.let { it == institution.id } ?: true, + institution = institution, + index = index, + onInstitutionSelected = { onInstitutionSelected(it, false) } + ) + } + ) + if (institutions().showManualEntry == true) { + item { + ManualEntryRow( + modifier = Modifier.padding(8.dp), + enabled = selectedInstitutionId == null, + onManualEntryClick = onManualEntryClick + ) + } + } + } } } } @Composable -private fun FinancialConnectionsSearchRow( - query: TextFieldValue, - onQueryChanged: (TextFieldValue) -> Unit, - onCancelSearchClick: () -> Unit, - onSearchFocused: () -> Unit, - searchMode: Boolean +private fun NoResultsTile( + modifier: Modifier = Modifier, + showManualEntry: Boolean?, + onManualEntryClick: () -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = R.string.stripe_institutionpicker_pane_error_title), + style = typography.headingLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + AnnotatedText( + text = when (showManualEntry ?: false) { + true -> TextResource.StringId(R.string.stripe_institutionpicker_pane_error_desc_manual_entry) + false -> TextResource.StringId(R.string.stripe_institutionpicker_pane_error_desc) + }, + onClickableTextClick = { onManualEntryClick() }, + defaultStyle = typography.bodyMedium.copy( + textAlign = TextAlign.Center, + ), + annotationStyles = mapOf( + StringAnnotation.CLICKABLE to typography.bodyMediumEmphasized.toSpanStyle().copy( + color = colors.textBrand, + ) + ), + ) + } +} + +@Composable +private fun SearchTitle(modifier: Modifier = Modifier) { + Text( + modifier = modifier.fillMaxWidth(), + text = stringResource(R.string.stripe_institutionpicker_pane_select_bank), + style = typography.headingXLarge + ) +} + +@Composable +private fun SearchRow( + modifier: Modifier = Modifier, + focusRequester: FocusRequester, + query: String, + onQueryChanged: (String) -> Unit, ) { val focusManager = LocalFocusManager.current - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 24.dp) + Box( + modifier = modifier + .fillMaxWidth() + .background(colors.backgroundSurface) + .padding(top = 0.dp, bottom = 8.dp, start = 8.dp, end = 8.dp) ) { FinancialConnectionsOutlinedTextField( + modifier = modifier + .fillMaxWidth() + .focusRequester(focusRequester), + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.stripe_ic_search), + tint = colors.iconDefault, + contentDescription = "Search icon", + ) + }, keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Search, ), - leadingIcon = if (searchMode) { - { - Icon( - Icons.Filled.ArrowBack, - tint = FinancialConnectionsTheme.colors.textPrimary, - contentDescription = "Back button", - modifier = Modifier.clickable { - onCancelSearchClick() - focusManager.clearFocus() - } - ) - } - } else { - { - Icon( - Icons.Filled.Search, - tint = FinancialConnectionsTheme.colors.textPrimary, - contentDescription = "Search icon", - ) - } - }, - modifier = Modifier - .onFocusChanged { if (it.isFocused) onSearchFocused() } - .weight(1f), + keyboardActions = KeyboardActions( + onSearch = { focusManager.clearFocus() }, + ), + trailingIcon = query + .takeIf { it.isNotEmpty() } + ?.let { + { ClearSearchButton(onQueryChanged = onQueryChanged, colors = colors) } + }, placeholder = { Text( text = stringResource(id = R.string.stripe_search), - style = FinancialConnectionsTheme.typography.body, - color = FinancialConnectionsTheme.colors.textDisabled + style = typography.labelLarge, + color = colors.textSubdued ) }, value = query, + enabled = true, onValueChange = { onQueryChanged(it) } ) } } @Composable -private fun SearchInstitutionsList( - institutions: Async , - onInstitutionSelected: (FinancialConnectionsInstitution, Boolean) -> Unit, - onManualEntryClick: () -> Unit, - onScrollChanged: () -> Unit, - allowManualEntry: Boolean +private fun ClearSearchButton( + onQueryChanged: (String) -> Unit, + colors: FinancialConnectionsColors ) { - val listState = rememberLazyListState() - val shouldEmitScrollEvent = remember { mutableStateOf(true) } - - // Scroll event should be emitted just once per search - LaunchedEffect(institutions) { shouldEmitScrollEvent.value = true } - // Trigger onScrollChanged with the list of institutions when scrolling stops (true -> false) - LaunchedEffect(listState.isScrollInProgress) { - if (institutions()?.data?.isNotEmpty() == true && - !listState.isScrollInProgress && - shouldEmitScrollEvent.value - ) { - onScrollChanged() - shouldEmitScrollEvent.value = false - } + Box( + Modifier + .size(16.dp) + .clickable { onQueryChanged("") } + .background( + color = colors.border, + shape = CircleShape + ) + .padding(2.dp) + ) { + Icon( + Icons.Filled.Clear, + tint = colors.backgroundSurface, + contentDescription = "Clear search", + ) } - - LazyColumn( - state = listState, - horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = PaddingValues(top = 16.dp), - content = { - when (institutions) { - Uninitialized, - is Fail -> item { - if (allowManualEntry) { - ManualEntryRow(onManualEntryClick) - } else { - NoResultsRow() - } - } - - is Loading -> item { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { LoadingSpinner() } - } - - is Success -> { - if (institutions().data.isEmpty()) { - // NO RESULTS CASE - item { - if (institutions().showManualEntry == true) { - ManualEntryRow(onManualEntryClick) - } else { - NoResultsRow() - } - } - } else { - itemsIndexed( - items = institutions().data, - key = { _, institution -> institution.id }, - itemContent = { index, institution -> - InstitutionResultTile( - institution = institution, - index = index - ) { onInstitutionSelected(it, false) } - } - ) - if (institutions().showManualEntry == true) { - item { - Divider( - modifier = Modifier - .fillMaxWidth() - .padding( - vertical = 8.dp, - horizontal = 24.dp - ), - color = FinancialConnectionsTheme.colors.borderDefault - ) - } - item { - ManualEntryRow(onManualEntryClick) - } - } - } - } - } - } - ) } @Composable -private fun NoResultsRow() { +private fun ManualEntryRow( + modifier: Modifier = Modifier, + enabled: Boolean, + onManualEntryClick: () -> Unit +) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier + modifier = modifier .fillMaxSize() - .padding( - vertical = 8.dp, - horizontal = 24.dp + .clickable( + enabled = enabled, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onManualEntryClick ) + .alpha(if (enabled) 1f else DISABLED_DEPTH_ALPHA) ) { + ShapedIcon( + backgroundShape = RoundedCornerShape(12.dp), + painter = painterResource(id = R.drawable.stripe_ic_add), + contentDescription = "Manually enter details" + ) + + Spacer(modifier = Modifier.size(8.dp)) Column { Text( - text = stringResource(id = R.string.stripe_institutionpicker_no_results_title), - color = FinancialConnectionsTheme.colors.textPrimary, - style = FinancialConnectionsTheme.typography.bodyEmphasized + text = stringResource(R.string.stripe_institutionpicker_manual_entry_title), + color = colors.textDefault, + style = typography.labelLargeEmphasized, ) Text( - text = stringResource(id = R.string.stripe_institutionpicker_no_results_desc), - color = FinancialConnectionsTheme.colors.textSecondary, - style = FinancialConnectionsTheme.typography.captionTight, + text = stringResource(R.string.stripe_institutionpicker_manual_entry_desc), + color = colors.textSubdued, + style = typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -385,208 +474,147 @@ private fun NoResultsRow() { } @Composable -private fun ManualEntryRow(onManualEntryClick: () -> Unit) { +private fun SearchMoreRow( + modifier: Modifier = Modifier, + onClick: () -> Unit, + enabled: Boolean +) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier + modifier = modifier .fillMaxSize() - .clickable(onClick = onManualEntryClick) - .padding( - vertical = 8.dp, - horizontal = 24.dp + .clickable( + enabled = enabled, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick ) + .alpha(if (enabled) 1f else DISABLED_DEPTH_ALPHA) ) { - Icon( - imageVector = Icons.Filled.Add, - tint = FinancialConnectionsTheme.colors.textBrand, - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Brand100) - .padding(8.dp), + ShapedIcon( + backgroundShape = RoundedCornerShape(12.dp), + painter = painterResource(id = R.drawable.stripe_ic_search), contentDescription = "Add icon" ) Spacer(modifier = Modifier.size(8.dp)) - Column { - Text( - text = stringResource(R.string.stripe_institutionpicker_manual_entry_title), - color = FinancialConnectionsTheme.colors.textPrimary, - style = FinancialConnectionsTheme.typography.bodyEmphasized - ) - Text( - text = stringResource(R.string.stripe_institutionpicker_manual_entry_desc), - color = FinancialConnectionsTheme.colors.textSecondary, - style = FinancialConnectionsTheme.typography.captionTight, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + Text( + text = stringResource(R.string.stripe_institutionpicker_search_more_title), + color = colors.textDefault, + style = typography.labelLargeEmphasized, + ) } } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun InstitutionResultTile( + modifier: Modifier = Modifier, institution: FinancialConnectionsInstitution, + loading: Boolean = false, + enabled: Boolean = true, index: Int, onInstitutionSelected: (FinancialConnectionsInstitution) -> Unit ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier + modifier = modifier .fillMaxSize() .semantics { testTagsAsResourceId = true } .testTag("search_result_$index") - .clickableSingle { onInstitutionSelected(institution) } - .padding( - vertical = 8.dp, - horizontal = 24.dp - ) + .clickable( + enabled = enabled && loading.not(), + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onInstitutionSelected(institution) } + .alpha(if (enabled) 1f else DISABLED_DEPTH_ALPHA) ) { - val modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(6.dp)) - when { - institution.icon?.default.isNullOrEmpty() -> InstitutionPlaceholder(modifier) - else -> StripeImage( - url = requireNotNull(institution.icon?.default), - imageLoader = LocalImageLoader.current, - contentDescription = null, - modifier = modifier, - contentScale = ContentScale.Crop, - errorContent = { InstitutionPlaceholder(modifier) } - ) - } + InstitutionIcon(institution.icon?.default) Spacer(modifier = Modifier.size(8.dp)) - Column { + Column( + modifier = Modifier.weight(1f) + ) { Text( text = institution.name, - color = FinancialConnectionsTheme.colors.textPrimary, - style = FinancialConnectionsTheme.typography.bodyEmphasized + maxLines = 1, + color = colors.textDefault, + style = typography.labelLargeEmphasized, + overflow = TextOverflow.Ellipsis ) Text( - text = institution.url ?: "", - color = FinancialConnectionsTheme.colors.textDisabled, - style = FinancialConnectionsTheme.typography.captionTight, + text = institution.formattedUrl, + color = colors.textSubdued, + style = typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis ) } + // add a trailing icon if this is the manual entry row + if (loading) { + Spacer(modifier = Modifier.size(8.dp)) + LoadingSpinner(modifier = Modifier.size(24.dp)) + } } } @Composable -private fun FeaturedInstitutionsGrid( - modifier: Modifier, - payload: Async , - onInstitutionSelected: (FinancialConnectionsInstitution, Boolean) -> Unit -) { - LazyVerticalGrid( - modifier = modifier, - columns = GridCells.Fixed(2), - contentPadding = PaddingValues( - top = 16.dp, - start = 24.dp, - end = 24.dp, - bottom = 16.dp - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = { - when (payload) { - Uninitialized, is Loading -> { - item(span = { GridItemSpan(2) }) { - LoadingSpinner() - } - } - // Show empty featured institutions grid. Users will be able to search using search bar. - is Fail -> Unit - is Success -> items(payload().featuredInstitutions) { institution -> - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .height(80.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .border( - width = 1.dp, - color = FinancialConnectionsTheme.colors.borderDefault, - shape = RoundedCornerShape(6.dp) - ) - .clickableSingle( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple( - color = FinancialConnectionsTheme.colors.textSecondary - ), - ) { onInstitutionSelected(institution, true) } - ) { - when { - institution.logo?.default.isNullOrBlank() -> - FeaturedInstitutionPlaceholder(institution) +private fun InstitutionResultShimmer(modifier: Modifier) { + LoadingShimmerEffect { shimmer -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(6.dp)) + .background(shimmer) + ) + Spacer(modifier = Modifier.size(8.dp)) + Column { + Box( + modifier = Modifier + .fillMaxWidth(0.75f) + .height(16.dp) + .clip(RoundedCornerShape(6.dp)) + .background(shimmer) - else -> StripeImage( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - url = requireNotNull(institution.logo?.default), - imageLoader = LocalImageLoader.current, - contentScale = ContentScale.Fit, - loadingContent = { FeaturedInstitutionLoading() }, - errorContent = { FeaturedInstitutionPlaceholder(institution) }, - contentDescription = "Institution logo" - ) - } - } - } + ) + Spacer(modifier = Modifier.size(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth(0.5f) + .height(16.dp) + .clip(RoundedCornerShape(6.dp)) + .background(shimmer) + ) } } - ) -} - -@Composable -private fun BoxScope.FeaturedInstitutionLoading() { - LoadingShimmerEffect { shimmer -> - Spacer( - modifier = Modifier.Companion - .align(Alignment.Center) - .height(20.dp) - .clip(RoundedCornerShape(10.dp)) - .fillMaxWidth(fraction = 0.5f) - .background(shimmer) - ) } } -@Composable -private fun BoxScope.FeaturedInstitutionPlaceholder(institution: FinancialConnectionsInstitution) { - Text( - modifier = Modifier.Companion.align(Alignment.Center), - text = institution.name, - color = FinancialConnectionsTheme.colors.textPrimary, - style = FinancialConnectionsTheme.typography.bodyEmphasized, - textAlign = TextAlign.Center - ) -} +private const val DISABLED_DEPTH_ALPHA = 0.3f @Preview(group = "Institution Picker Pane") @Composable internal fun InstitutionPickerPreview( @PreviewParameter(InstitutionPickerPreviewParameterProvider::class) - state: InstitutionPickerState + previewState: InstitutionPickerPreviewParameterProvider.InstitutionPreviewState ) { + val state = previewState.state + val listState = rememberLazyListState( + initialFirstVisibleItemScrollOffset = previewState.initialScroll + ) FinancialConnectionsPreview { InstitutionPickerContent( + listState = listState, payload = state.payload, institutions = state.searchInstitutions, - searchMode = state.searchMode, previewText = state.previewText, + selectedInstitutionId = state.selectedInstitutionId, onQueryChanged = {}, onInstitutionSelected = { _, _ -> }, - onCancelSearchClick = {}, onCloseClick = {}, - onSearchFocused = {}, onManualEntryClick = {}, - onScrollChanged = {}, - ) + ) {} } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerViewModel.kt index e71bf448f73..36ab15ff383 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerViewModel.kt @@ -5,7 +5,6 @@ import com.airbnb.mvrx.Loading import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.MavericksViewModelFactory -import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext import com.stripe.android.core.Logger @@ -21,15 +20,19 @@ import com.stripe.android.financialconnections.analytics.FinancialConnectionsEve import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.Name import com.stripe.android.financialconnections.analytics.logError import com.stripe.android.financialconnections.domain.FeaturedInstitutions -import com.stripe.android.financialconnections.domain.GetManifest +import com.stripe.android.financialconnections.domain.GetOrFetchSync +import com.stripe.android.financialconnections.domain.HandleError +import com.stripe.android.financialconnections.domain.PostAuthorizationSession import com.stripe.android.financialconnections.domain.SearchInstitutions import com.stripe.android.financialconnections.domain.UpdateLocalManifest import com.stripe.android.financialconnections.features.institutionpicker.InstitutionPickerState.Payload +import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.InstitutionResponse import com.stripe.android.financialconnections.navigation.Destination.ManualEntry import com.stripe.android.financialconnections.navigation.Destination.PartnerAuth +import com.stripe.android.financialconnections.navigation.Destination.PartnerAuthDrawer import com.stripe.android.financialconnections.navigation.NavigationManager import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity import com.stripe.android.financialconnections.utils.ConflatedJob @@ -39,13 +42,14 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") internal class InstitutionPickerViewModel @Inject constructor( private val configuration: FinancialConnectionsSheet.Configuration, + private val postAuthorizationSession: PostAuthorizationSession, + private val getOrFetchSync: GetOrFetchSync, private val searchInstitutions: SearchInstitutions, private val featuredInstitutions: FeaturedInstitutions, - private val getManifest: GetManifest, private val eventTracker: FinancialConnectionsAnalyticsTracker, + private val handleError: HandleError, private val navigationManager: NavigationManager, private val updateLocalManifest: UpdateLocalManifest, private val logger: Logger, @@ -57,29 +61,31 @@ internal class InstitutionPickerViewModel @Inject constructor( init { logErrors() suspend { - val manifest = getManifest() - val result: Result , Long>> = - kotlin.runCatching { - measureTimeMillis { - featuredInstitutions( - clientSecret = configuration.financialConnectionsSessionClientSecret - ).data - } - }.onFailure { - eventTracker.logError( - extraMessage = "Error fetching featured institutions", - error = it, - pane = Pane.INSTITUTION_PICKER, - logger = logger + val manifest = getOrFetchSync().manifest + val (featuredInstitutions: InstitutionResponse, duration: Long) = runCatching { + measureTimeMillis { + featuredInstitutions( + clientSecret = configuration.financialConnectionsSessionClientSecret ) } - - val (institutions, duration) = result.getOrNull() ?: Pair(emptyList(), 0L) + }.onFailure { + eventTracker.logError( + extraMessage = "Error fetching featured institutions", + error = it, + pane = PANE, + logger = logger + ) + }.getOrElse { + // Allow users to search for institutions even if featured institutions fails. + InstitutionResponse( + data = emptyList(), + showManualEntry = manifest.allowManualEntry + ) to 0L + } Payload( featuredInstitutionsDuration = duration, - featuredInstitutions = institutions, + featuredInstitutions = featuredInstitutions, searchDisabled = manifest.institutionSearchDisabled, - allowManualEntry = manifest.allowManualEntry ) }.execute { copy(payload = it) } } @@ -88,43 +94,43 @@ internal class InstitutionPickerViewModel @Inject constructor( onAsync( InstitutionPickerState::payload, onSuccess = { payload -> - eventTracker.track(PaneLoaded(Pane.INSTITUTION_PICKER)) + eventTracker.track(PaneLoaded(PANE)) eventTracker.track( FeaturedInstitutionsLoaded( - pane = Pane.INSTITUTION_PICKER, + pane = PANE, duration = payload.featuredInstitutionsDuration, - institutionIds = payload.featuredInstitutions.map { it.id }.toSet() + institutionIds = payload.featuredInstitutions.data.map { it.id }.toSet() ) ) }, onFail = { - eventTracker.logError( + handleError( extraMessage = "Error fetching initial payload", error = it, - pane = Pane.INSTITUTION_PICKER, - logger = logger + pane = PANE, + displayErrorScreen = true ) } ) onAsync( InstitutionPickerState::searchInstitutions, onFail = { - eventTracker.logError( + handleError( extraMessage = "Error searching institutions", error = it, - pane = Pane.INSTITUTION_PICKER, - logger = logger + pane = PANE, + displayErrorScreen = false // don't show error screen for search errors. ) } ) onAsync( - InstitutionPickerState::selectInstitution, + InstitutionPickerState::createSessionForInstitution, onFail = { - eventTracker.logError( - extraMessage = "Error selecting institution institutions", + handleError( + extraMessage = "Error selecting or creating session for institution", error = it, - pane = Pane.INSTITUTION_PICKER, - logger = logger + pane = PANE, + displayErrorScreen = true ) } ) @@ -142,7 +148,7 @@ internal class InstitutionPickerViewModel @Inject constructor( } eventTracker.track( SearchSucceeded( - pane = Pane.INSTITUTION_PICKER, + pane = PANE, query = query, duration = millis, resultCount = result.data.count() @@ -162,11 +168,10 @@ internal class InstitutionPickerViewModel @Inject constructor( } fun onInstitutionSelected(institution: FinancialConnectionsInstitution, fromFeatured: Boolean) { - clearSearch() suspend { eventTracker.track( InstitutionSelected( - pane = Pane.INSTITUTION_PICKER, + pane = PANE, fromFeatured = fromFeatured, institutionId = institution.id ) @@ -183,43 +188,44 @@ internal class InstitutionPickerViewModel @Inject constructor( ) } // navigate to next step - navigationManager.tryNavigateTo(PartnerAuth(referrer = Pane.INSTITUTION_PICKER)) - }.execute { this } - } - - fun onCancelSearchClick() { - clearSearch() - } - - private fun clearSearch() { - setState { + val authSession = postAuthorizationSession(institution, getOrFetchSync()) + navigateToPartnerAuth(authSession) + }.execute { async -> copy( - searchInstitutions = Success( - InstitutionResponse( - data = emptyList(), - showManualEntry = false - ) - ), - searchMode = false + selectedInstitutionId = institution.id.takeIf { async is Loading }, + createSessionForInstitution = async ) } } - fun onSearchFocused() { - setState { - copy(searchMode = true) - } + /** + * Navigates to the partner auth screen: + * + * - If the [authSession] is OAuth, it will navigate to [PartnerAuthDrawer]. The pre-pane will show + * as a bottom sheet, where users can accept which will open the browser with the bank OAuth login. + * - If the [authSession] is not-OAuth, it will navigate to [PartnerAuth] (full screen). + * non-OAuth sessions don't have a pre-pane, so partner auth will show a full-screen loading + * and open the browser right away. + */ + private fun navigateToPartnerAuth(authSession: FinancialConnectionsAuthorizationSession) { + navigationManager.tryNavigateTo( + if (authSession.isOAuth) { + PartnerAuthDrawer(referrer = PANE) + } else { + PartnerAuth(referrer = PANE) + } + ) } fun onManualEntryClick() { - navigationManager.tryNavigateTo(ManualEntry(referrer = Pane.INSTITUTION_PICKER)) + navigationManager.tryNavigateTo(ManualEntry(referrer = PANE)) } fun onScrollChanged() { viewModelScope.launch { eventTracker.track( SearchScroll( - pane = Pane.INSTITUTION_PICKER, + pane = PANE, institutionIds = awaitState().searchInstitutions() ?.data ?.map { it.id } @@ -233,6 +239,7 @@ internal class InstitutionPickerViewModel @Inject constructor( MavericksViewModelFactory { private const val SEARCH_DEBOUNCE_MS = 300L + private val PANE = Pane.INSTITUTION_PICKER override fun create( viewModelContext: ViewModelContext, state: InstitutionPickerState @@ -251,15 +258,14 @@ internal class InstitutionPickerViewModel @Inject constructor( internal data class InstitutionPickerState( // This is just used to provide a text in Compose previews val previewText: String? = null, - val searchMode: Boolean = false, + val selectedInstitutionId: String? = null, val payload: Async = Uninitialized, val searchInstitutions: Async = Uninitialized, - val selectInstitution: Async = Uninitialized + val createSessionForInstitution: Async = Uninitialized ) : MavericksState { data class Payload( - val featuredInstitutions: List , - val allowManualEntry: Boolean, + val featuredInstitutions: InstitutionResponse, val searchDisabled: Boolean, val featuredInstitutionsDuration: Long ) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerPreviewParameterProvider.kt index b9ae39071e9..72d201d2f8f 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerPreviewParameterProvider.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerPreviewParameterProvider.kt @@ -1,13 +1,17 @@ -@file:Suppress("LongMethod") - package com.stripe.android.financialconnections.features.linkaccountpicker import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Success -import com.stripe.android.financialconnections.features.common.AccessibleDataCalloutModel +import com.stripe.android.financialconnections.features.common.MerchantDataAccessModel import com.stripe.android.financialconnections.model.AddNewAccount +import com.stripe.android.financialconnections.model.Bullet +import com.stripe.android.financialconnections.model.ConnectedAccessNotice +import com.stripe.android.financialconnections.model.DataAccessNotice +import com.stripe.android.financialconnections.model.DataAccessNoticeBody import com.stripe.android.financialconnections.model.FinancialConnectionsAccount import com.stripe.android.financialconnections.model.FinancialConnectionsAccount.Status +import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.Image import com.stripe.android.financialconnections.model.NetworkedAccount @@ -18,19 +22,39 @@ internal class LinkAccountPickerPreviewParameterProvider : PreviewParameterProvider { override val values = sequenceOf( canonical(), - accountSelected() + loading(), + accountSelected(), + oneAccount() ) - override val count: Int - get() = super.count - private fun canonical() = LinkAccountPickerState( payload = Success( LinkAccountPickerState.Payload( title = display().title, accounts = partnerAccountList(), + dataAccessNotice = dataAccessNotice(), + addNewAccount = requireNotNull(display().addNewAccount), + merchantDataAccess = accessibleCallout(), + consumerSessionClientSecret = "secret", + defaultCta = display().defaultCta, + nextPaneOnNewAccount = Pane.INSTITUTION_PICKER, + partnerToCoreAuths = emptyMap(), + ) + ), + ) + + private fun loading() = LinkAccountPickerState( + payload = Loading(), + ) + + private fun oneAccount() = LinkAccountPickerState( + payload = Success( + LinkAccountPickerState.Payload( + title = display().title, + accounts = partnerAccountList().subList(0, 1), + dataAccessNotice = dataAccessNotice(), addNewAccount = requireNotNull(display().addNewAccount), - accessibleData = accessibleCallout(), + merchantDataAccess = accessibleCallout(), consumerSessionClientSecret = "secret", defaultCta = display().defaultCta, nextPaneOnNewAccount = Pane.INSTITUTION_PICKER, @@ -45,8 +69,9 @@ internal class LinkAccountPickerPreviewParameterProvider : LinkAccountPickerState.Payload( title = display().title, accounts = partnerAccountList(), + dataAccessNotice = dataAccessNotice(), addNewAccount = requireNotNull(display().addNewAccount), - accessibleData = accessibleCallout(), + merchantDataAccess = accessibleCallout(), consumerSessionClientSecret = "secret", defaultCta = display().defaultCta, nextPaneOnNewAccount = Pane.INSTITUTION_PICKER, @@ -64,6 +89,7 @@ internal class LinkAccountPickerPreviewParameterProvider : balanceAmount = 1000, status = Status.ACTIVE, displayableAccountNumbers = "1234", + institution = institution(), currency = "USD", _allowSelection = true, allowSelectionMessage = "", @@ -87,6 +113,7 @@ internal class LinkAccountPickerPreviewParameterProvider : balanceAmount = 1000, status = Status.ACTIVE, displayableAccountNumbers = "1234", + institution = institution(), currency = "USD", _allowSelection = true, allowSelectionMessage = "", @@ -103,6 +130,7 @@ internal class LinkAccountPickerPreviewParameterProvider : id = "id2", name = "With balance disabled", balanceAmount = 1000, + institution = institution(), _allowSelection = false, allowSelectionMessage = "Disconnected", subcategory = FinancialConnectionsAccount.Subcategory.SAVINGS, @@ -117,6 +145,7 @@ internal class LinkAccountPickerPreviewParameterProvider : id = "id3", name = "No balance", displayableAccountNumbers = "1234", + institution = institution(), subcategory = FinancialConnectionsAccount.Subcategory.CREDIT_CARD, _allowSelection = true, allowSelectionMessage = "", @@ -131,6 +160,7 @@ internal class LinkAccountPickerPreviewParameterProvider : id = "id4", name = "No balance disabled", displayableAccountNumbers = "1234", + institution = institution(), subcategory = FinancialConnectionsAccount.Subcategory.CHECKING, _allowSelection = false, allowSelectionMessage = "Disconnected", @@ -145,6 +175,7 @@ internal class LinkAccountPickerPreviewParameterProvider : id = "id5", name = "Very long institution that is already linked", displayableAccountNumbers = "1234", + institution = institution(), linkedAccountId = "linkedAccountId", _allowSelection = true, subcategory = FinancialConnectionsAccount.Subcategory.CHECKING, @@ -155,7 +186,37 @@ internal class LinkAccountPickerPreviewParameterProvider : ), ) - private fun accessibleCallout() = AccessibleDataCalloutModel( + private fun dataAccessNotice() = DataAccessNotice( + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Goldilocks uses Stripe to link your accounts", + subtitle = "Goldilocks will use your account and routing number, balances and transactions:", + body = DataAccessNoticeBody( + bullets = bullets() + ), + disclaimer = "Learn more about data access", + connectedAccountNotice = ConnectedAccessNotice( + subtitle = "Connected account placeholder", + body = DataAccessNoticeBody( + bullets = bullets() + ) + ), + cta = "OK" + ) + + private fun bullets() = listOf( + Bullet( + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Account details", + content = "Account number, routing number, account type, account nickname." + ), + Bullet( + icon = Image("https://www.cdn.stripe.com/12321312321.png"), + title = "Account details", + content = "Account number, routing number, account type, account nickname." + ), + ) + + private fun accessibleCallout() = MerchantDataAccessModel( businessName = "My business", permissions = listOf( FinancialConnectionsAccount.Permissions.PAYMENT_METHOD, @@ -164,8 +225,6 @@ internal class LinkAccountPickerPreviewParameterProvider : FinancialConnectionsAccount.Permissions.TRANSACTIONS ), isStripeDirect = true, - isNetworking = true, - dataPolicyUrl = "" ) fun display() = ReturningNetworkingUserAccountPicker( @@ -179,4 +238,14 @@ internal class LinkAccountPickerPreviewParameterProvider : ), ) ) + + fun institution() = FinancialConnectionsInstitution( + name = "Bank of America", + featured = true, + mobileHandoffCapable = true, + id = "in_123", + icon = Image( + default = "https://b.stripecdn.com/connections-statics-srv/assets/InstitutionIcons/bankofamerica.png" + ), + ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerScreen.kt index 639b04d66be..a0fbada5b06 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerScreen.kt @@ -4,32 +4,39 @@ package com.stripe.android.financialconnections.features.linkaccountpicker import androidx.activity.compose.BackHandler import androidx.annotation.RestrictTo -import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue.Hidden import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -42,13 +49,15 @@ import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.features.common.AccessibleDataCallout import com.stripe.android.financialconnections.features.common.AccountItem -import com.stripe.android.financialconnections.features.common.InstitutionPlaceholder -import com.stripe.android.financialconnections.features.common.LoadingContent -import com.stripe.android.financialconnections.features.common.PaneFooter +import com.stripe.android.financialconnections.features.common.DataAccessBottomSheetContent +import com.stripe.android.financialconnections.features.common.LoadingShimmerEffect +import com.stripe.android.financialconnections.features.common.MerchantDataAccessText import com.stripe.android.financialconnections.features.common.UnclassifiedErrorContent +import com.stripe.android.financialconnections.features.linkaccountpicker.LinkAccountPickerClickableText.DATA import com.stripe.android.financialconnections.features.linkaccountpicker.LinkAccountPickerState.Payload +import com.stripe.android.financialconnections.features.linkaccountpicker.LinkAccountPickerState.ViewEffect.OpenBottomSheet +import com.stripe.android.financialconnections.features.linkaccountpicker.LinkAccountPickerState.ViewEffect.OpenUrl import com.stripe.android.financialconnections.model.AddNewAccount import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.NetworkedAccount @@ -63,20 +72,55 @@ import com.stripe.android.financialconnections.ui.components.FinancialConnection import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar import com.stripe.android.financialconnections.ui.components.clickableSingle import com.stripe.android.financialconnections.ui.components.elevation -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography +import com.stripe.android.financialconnections.ui.theme.LazyLayout +import com.stripe.android.financialconnections.ui.theme.Neutral900 import com.stripe.android.uicore.image.StripeImage +import kotlinx.coroutines.launch +/* + The returning user account picker contains a lot of logic handling what happens after a user selects an account. + Accounts might require step-up verification, repair, or relinking to grant additional permissions (supportability). + - For flows where users are only allowed to select one account, the next pane to display is just whatever is set as + the `next_pane_on_selection` from the API + - For flows where users may select multiple accounts, we use the following logic: + - If a selected account requires repair, we immediately pop up a drawer to initiate the repair flow + - If a selected account requires additional permissions to be shared (supportability), + we immediately pop up a drawer to initiate partner auth + - If a selected account requires step-up verification, we assume that all accounts require step-up verification + and push the step-up verification pane after they click the CTA + - If a selected account does not require any further action, we continue to the success pane +*/ @Composable internal fun LinkAccountPickerScreen() { val viewModel: LinkAccountPickerViewModel = mavericksViewModel() val parentViewModel = parentViewModel() val state = viewModel.collectAsState() BackHandler(enabled = true) {} + + val bottomSheetState = rememberModalBottomSheetState( + initialValue = Hidden, + skipHalfExpanded = true + ) + val uriHandler = LocalUriHandler.current + + state.value.viewEffect?.let { viewEffect -> + LaunchedEffect(viewEffect) { + when (viewEffect) { + is OpenUrl -> uriHandler.openUri(viewEffect.url) + is OpenBottomSheet -> bottomSheetState.show() + } + viewModel.onViewEffectLaunched() + } + } + LinkAccountPickerContent( state = state.value, + bottomSheetState = bottomSheetState, onCloseClick = { parentViewModel.onCloseWithConfirmationClick(Pane.LINK_ACCOUNT_PICKER) }, onCloseFromErrorClick = parentViewModel::onCloseFromErrorClick, - onLearnMoreAboutDataAccessClick = viewModel::onLearnMoreAboutDataAccessClick, + onClickableTextClick = viewModel::onClickableTextClick, onNewBankAccountClick = viewModel::onNewBankAccountClick, onSelectAccountClick = viewModel::onSelectAccountClick, onAccountClick = viewModel::onAccountClick @@ -86,32 +130,76 @@ internal fun LinkAccountPickerScreen() { @Composable private fun LinkAccountPickerContent( state: LinkAccountPickerState, + bottomSheetState: ModalBottomSheetState, onCloseClick: () -> Unit, onCloseFromErrorClick: (Throwable) -> Unit, - onLearnMoreAboutDataAccessClick: () -> Unit, + onClickableTextClick: (String) -> Unit, onNewBankAccountClick: () -> Unit, onSelectAccountClick: () -> Unit, onAccountClick: (PartnerAccount) -> Unit ) { - val scrollState = rememberScrollState() + val scrollState = rememberLazyListState() + val scope = rememberCoroutineScope() + ModalBottomSheetLayout( + sheetState = bottomSheetState, + sheetBackgroundColor = colors.backgroundSurface, + sheetShape = RoundedCornerShape(8.dp), + scrimColor = Neutral900.copy(alpha = 0.32f), + sheetContent = { + when (val dataAccessNotice = state.payload()?.dataAccessNotice) { + null -> Unit + else -> DataAccessBottomSheetContent( + dataDialog = dataAccessNotice, + onConfirmModalClick = { scope.launch { bottomSheetState.hide() } }, + onClickableTextClick = onClickableTextClick + ) + } + }, + content = { + LinkAccountPickerMainContent( + scrollState = scrollState, + onCloseClick = onCloseClick, + state = state, + onClickableTextClick = onClickableTextClick, + onSelectAccountClick = onSelectAccountClick, + onNewBankAccountClick = onNewBankAccountClick, + onAccountClick = onAccountClick, + onCloseFromErrorClick = onCloseFromErrorClick + ) + }, + ) +} + +@Composable +private fun LinkAccountPickerMainContent( + scrollState: LazyListState, + onCloseClick: () -> Unit, + state: LinkAccountPickerState, + onClickableTextClick: (String) -> Unit, + onSelectAccountClick: () -> Unit, + onNewBankAccountClick: () -> Unit, + onAccountClick: (PartnerAccount) -> Unit, + onCloseFromErrorClick: (Throwable) -> Unit +) { FinancialConnectionsScaffold( topBar = { FinancialConnectionsTopAppBar( - showBack = false, + allowBackNavigation = false, elevation = scrollState.elevation, onCloseClick = onCloseClick ) } ) { when (val payload = state.payload) { - Uninitialized, is Loading -> LinkAccountPickerLoading() + Uninitialized, + is Loading, is Success -> LinkAccountPickerLoaded( scrollState = scrollState, - payload = payload(), + payload = payload, cta = state.cta, selectedAccountId = state.selectedAccountId, selectNetworkedAccountAsync = state.selectNetworkedAccountAsync, - onLearnMoreAboutDataAccessClick = onLearnMoreAboutDataAccessClick, + onClickableTextClick = onClickableTextClick, onSelectAccountClick = onSelectAccountClick, onNewBankAccountClick = onNewBankAccountClick, onAccountClick = onAccountClick @@ -125,72 +213,107 @@ private fun LinkAccountPickerContent( } } -@Composable -private fun LinkAccountPickerLoading() { - LoadingContent( - title = stringResource(R.string.stripe_account_picker_loading_title), - content = stringResource(R.string.stripe_account_picker_loading_desc) - ) -} - @Composable private fun LinkAccountPickerLoaded( + scrollState: LazyListState, + payload: Async , selectedAccountId: String?, selectNetworkedAccountAsync: Async , - payload: Payload, - onLearnMoreAboutDataAccessClick: () -> Unit, - onSelectAccountClick: () -> Unit, - onNewBankAccountClick: () -> Unit, onAccountClick: (PartnerAccount) -> Unit, - scrollState: ScrollState, + onNewBankAccountClick: () -> Unit, + onClickableTextClick: (String) -> Unit, + onSelectAccountClick: () -> Unit, cta: String? ) { - Column( - Modifier - .fillMaxSize() - ) { - Column( - modifier = Modifier - .verticalScroll(scrollState) - .padding(horizontal = 24.dp) - .weight(1f) - ) { - Spacer(modifier = Modifier.size(16.dp)) - Title(payload.title) - Spacer(modifier = Modifier.size(24.dp)) - payload.accounts.forEach { account -> - NetworkedAccountItem( - selected = account.first.id == selectedAccountId, - account = account, - onAccountClicked = { selected -> - if (selectNetworkedAccountAsync !is Loading) onAccountClick(selected) - } + LazyLayout( + verticalArrangement = Arrangement.spacedBy(16.dp), + lazyListState = scrollState, + body = { + payload()?.let { + loadedContent( + payload = it, + selectedAccountId = selectedAccountId, + selectNetworkedAccountAsync = selectNetworkedAccountAsync, + onAccountClick = onAccountClick, + onNewBankAccountClick = onNewBankAccountClick ) - Spacer(modifier = Modifier.height(12.dp)) - } - SelectNewAccount( - text = payload.addNewAccount, - onClick = { - if (selectNetworkedAccountAsync !is Loading) onNewBankAccountClick() + } ?: loadingContent() + }, + footer = { + payload()?.let { + Column { + MerchantDataAccessText( + model = it.merchantDataAccess, + onLearnMoreClick = { onClickableTextClick(DATA.value) } + ) + Spacer(modifier = Modifier.size(12.dp)) + FinancialConnectionsButton( + enabled = selectedAccountId != null, + loading = selectNetworkedAccountAsync is Loading, + onClick = onSelectAccountClick, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = cta ?: stringResource(R.string.stripe_link_account_picker_cta)) + } } - ) - Spacer(modifier = Modifier.size(16.dp)) + } } - PaneFooter(elevation = scrollState.elevation) { - AccessibleDataCallout( - payload.accessibleData, - onLearnMoreAboutDataAccessClick - ) - Spacer(modifier = Modifier.size(12.dp)) - FinancialConnectionsButton( - enabled = selectedAccountId != null, - loading = selectNetworkedAccountAsync is Loading, - onClick = onSelectAccountClick, + ) +} + +private fun LazyListScope.loadedContent( + payload: Payload, + selectedAccountId: String?, + selectNetworkedAccountAsync: Async , + onAccountClick: (PartnerAccount) -> Unit, + onNewBankAccountClick: () -> Unit +) { + item { + AnnotatedText( + text = TextResource.Text(payload.title), + defaultStyle = typography.headingXLarge, + onClickableTextClick = {} + ) + Spacer(modifier = Modifier.size(8.dp)) + } + items(payload.accounts) { + NetworkedAccountItem( + selected = it.first.id == selectedAccountId, + account = it, + onAccountClicked = { selected -> + if (selectNetworkedAccountAsync !is Loading) onAccountClick(selected) + } + ) + } + item { + SelectNewAccount( + text = payload.addNewAccount, + onClick = { + if (selectNetworkedAccountAsync !is Loading) onNewBankAccountClick() + } + ) + } +} + +private fun LazyListScope.loadingContent() { + item { + Text( + modifier = Modifier.fillMaxWidth(), + text = "Retrieving accounts", + style = typography.headingXLarge + ) + Spacer(modifier = Modifier.size(8.dp)) + } + items(3) { + LoadingShimmerEffect { + Box( modifier = Modifier .fillMaxWidth() - ) { - Text(text = cta ?: stringResource(R.string.stripe_link_account_picker_cta)) - } + .height(88.dp) + .clip(RoundedCornerShape(12.dp)) + .background(it) + ) } } } @@ -207,23 +330,7 @@ private fun NetworkedAccountItem( onAccountClicked = onAccountClicked, account = partnerAccount, networkedAccount = networkedAccount - ) { - val modifier = Modifier - .size(24.dp) - .clip(RoundedCornerShape(3.dp)) - val institutionIcon = partnerAccount.institution?.icon?.default - when { - institutionIcon.isNullOrEmpty() -> InstitutionPlaceholder(modifier) - else -> StripeImage( - url = institutionIcon, - imageLoader = LocalImageLoader.current, - contentDescription = null, - modifier = modifier, - contentScale = ContentScale.Crop, - errorContent = { InstitutionPlaceholder(modifier) } - ) - } - } + ) } @Composable @@ -231,18 +338,18 @@ private fun SelectNewAccount( onClick: () -> Unit, text: AddNewAccount ) { - val shape = remember { RoundedCornerShape(8.dp) } + val shape = remember { RoundedCornerShape(16.dp) } Box( modifier = Modifier .fillMaxWidth() .clip(shape) .border( width = 1.dp, - color = FinancialConnectionsTheme.colors.borderDefault, + color = colors.border, shape = shape ) - .clickableSingle { onClick() } .padding(16.dp) + .clickableSingle { onClick() } ) { Row( verticalAlignment = Alignment.CenterVertically @@ -254,8 +361,8 @@ private fun SelectNewAccount( Spacer(modifier = Modifier.size(16.dp)) Text( text = text.body, - style = FinancialConnectionsTheme.typography.body, - color = FinancialConnectionsTheme.colors.textBrand + style = typography.labelLargeEmphasized, + color = colors.textDefault ) } } @@ -266,60 +373,51 @@ fun SelectNewAccountIcon( icon: String?, contentDescription: String, ) { - Box { - val brandColor = FinancialConnectionsTheme.colors.textBrand - val modifier = Modifier - .size(12.dp) - .align(Alignment.Center) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(colors.backgroundOffset) + ) { + val iconModifier = Modifier.size(20.dp) val placeholderImage = @Composable { Image( - modifier = modifier, - imageVector = Icons.Filled.Add, - contentScale = ContentScale.FillBounds, - colorFilter = ColorFilter.tint(brandColor), + painter = painterResource(R.drawable.stripe_ic_add), + modifier = iconModifier, + colorFilter = ColorFilter.tint(colors.textBrand), contentDescription = contentDescription ) } - Canvas(modifier = Modifier.size(24.dp)) { - drawCircle(color = brandColor.copy(alpha = 0.1f)) - } when { - icon.isNullOrEmpty() -> placeholderImage() + LocalInspectionMode.current || + icon.isNullOrEmpty() -> placeholderImage() + else -> StripeImage( url = icon, imageLoader = LocalImageLoader.current, contentDescription = null, - modifier = modifier.padding(), + modifier = iconModifier, errorContent = { placeholderImage() } ) } } } -@Composable -private fun Title( - title: String -) { - AnnotatedText( - text = TextResource.Text(title), - defaultStyle = FinancialConnectionsTheme.typography.subtitle, - annotationStyles = emptyMap(), - onClickableTextClick = {} - ) -} - @Composable @Preview(group = "LinkAccountPicker Pane") internal fun LinkAccountPickerScreenPreview( @PreviewParameter(LinkAccountPickerPreviewParameterProvider::class) state: LinkAccountPickerState ) { + val bottomSheetState = rememberModalBottomSheetState(initialValue = Hidden) FinancialConnectionsPreview { LinkAccountPickerContent( state = state, + bottomSheetState = bottomSheetState, onCloseClick = {}, onCloseFromErrorClick = {}, - onLearnMoreAboutDataAccessClick = {}, + onClickableTextClick = {}, onNewBankAccountClick = {}, onSelectAccountClick = {}, onAccountClick = {} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt index a9f2f3336d4..caa588bb98d 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt @@ -16,13 +16,16 @@ import com.stripe.android.financialconnections.analytics.FinancialConnectionsEve import com.stripe.android.financialconnections.analytics.logError import com.stripe.android.financialconnections.domain.FetchNetworkedAccounts import com.stripe.android.financialconnections.domain.GetCachedConsumerSession -import com.stripe.android.financialconnections.domain.GetManifest +import com.stripe.android.financialconnections.domain.GetOrFetchSync import com.stripe.android.financialconnections.domain.SelectNetworkedAccount import com.stripe.android.financialconnections.domain.UpdateCachedAccounts import com.stripe.android.financialconnections.domain.UpdateLocalManifest -import com.stripe.android.financialconnections.features.common.AccessibleDataCalloutModel -import com.stripe.android.financialconnections.features.consent.FinancialConnectionsUrlResolver +import com.stripe.android.financialconnections.features.common.MerchantDataAccessModel +import com.stripe.android.financialconnections.features.linkaccountpicker.LinkAccountPickerClickableText.DATA +import com.stripe.android.financialconnections.features.linkaccountpicker.LinkAccountPickerState.ViewEffect.OpenBottomSheet +import com.stripe.android.financialconnections.features.linkaccountpicker.LinkAccountPickerState.ViewEffect.OpenUrl import com.stripe.android.financialconnections.model.AddNewAccount +import com.stripe.android.financialconnections.model.DataAccessNotice import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.NetworkedAccount import com.stripe.android.financialconnections.model.PartnerAccount @@ -31,19 +34,22 @@ import com.stripe.android.financialconnections.navigation.NavigationManager import com.stripe.android.financialconnections.navigation.destination import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity +import com.stripe.android.financialconnections.ui.HandleClickableUrl import kotlinx.coroutines.launch +import java.util.Date import javax.inject.Inject internal class LinkAccountPickerViewModel @Inject constructor( initialState: LinkAccountPickerState, private val eventTracker: FinancialConnectionsAnalyticsTracker, private val getCachedConsumerSession: GetCachedConsumerSession, + private val handleClickableUrl: HandleClickableUrl, private val fetchNetworkedAccounts: FetchNetworkedAccounts, private val selectNetworkedAccount: SelectNetworkedAccount, private val updateLocalManifest: UpdateLocalManifest, private val updateCachedAccounts: UpdateCachedAccounts, private val coreAuthorizationPendingNetworkingRepair: CoreAuthorizationPendingNetworkingRepairRepository, - private val getManifest: GetManifest, + private val getSync: GetOrFetchSync, private val navigationManager: NavigationManager, private val logger: Logger ) : MavericksViewModel (initialState) { @@ -51,13 +57,13 @@ internal class LinkAccountPickerViewModel @Inject constructor( init { observeAsyncs() suspend { - val manifest = getManifest() - val accessibleData = AccessibleDataCalloutModel( + val sync = getSync() + val manifest = sync.manifest + val dataAccessNotice = sync.text?.consent?.dataAccessNotice + val merchantDataAccess = MerchantDataAccessModel( businessName = manifest.businessName, permissions = manifest.permissions, - isNetworking = true, - isStripeDirect = manifest.isStripeDirect ?: false, - dataPolicyUrl = FinancialConnectionsUrlResolver.getDataPolicyUrl(manifest) + isStripeDirect = manifest.isStripeDirect ?: false ) val consumerSession = requireNotNull(getCachedConsumerSession()) val accountsResponse = fetchNetworkedAccounts(consumerSession.clientSecret) @@ -75,6 +81,7 @@ internal class LinkAccountPickerViewModel @Inject constructor( eventTracker.track(PaneLoaded(PANE)) LinkAccountPickerState.Payload( + dataAccessNotice = dataAccessNotice, partnerToCoreAuths = accountsResponse.partnerToCoreAuths, accounts = accounts, nextPaneOnNewAccount = accountsResponse.nextPaneOnAddAccount, @@ -83,7 +90,7 @@ internal class LinkAccountPickerViewModel @Inject constructor( defaultCta = display.defaultCta, consumerSessionClientSecret = consumerSession.clientSecret, // We always want to refer to Link rather than Stripe on Link panes. - accessibleData = accessibleData.copy(isStripeDirect = false) + merchantDataAccess = merchantDataAccess.copy(isStripeDirect = false) ) }.execute { copy(payload = it) } } @@ -114,9 +121,21 @@ internal class LinkAccountPickerViewModel @Inject constructor( ) } - fun onLearnMoreAboutDataAccessClick() { - // navigation to learn more about data access happens within the view component. - viewModelScope.launch { eventTracker.track(ClickLearnMoreDataAccess(PANE)) } + fun onClickableTextClick(uri: String) = viewModelScope.launch { + val date = Date() + handleClickableUrl( + currentPane = PANE, + uri = uri, + onNetworkUrlClicked = { + setState { copy(viewEffect = OpenUrl(uri, date.time)) } + }, + knownDeeplinkActions = mapOf( + DATA.value to { + eventTracker.track(ClickLearnMoreDataAccess(PANE)) + setState { copy(viewEffect = OpenBottomSheet(date.time)) } + } + ) + ) } fun onNewBankAccountClick() = viewModelScope.launch { @@ -169,6 +188,10 @@ internal class LinkAccountPickerViewModel @Inject constructor( setState { copy(selectedAccountId = partnerAccount.id) } } + fun onViewEffectLaunched() { + setState { copy(viewEffect = null) } + } + companion object : MavericksViewModelFactory { @@ -193,13 +216,15 @@ internal data class LinkAccountPickerState( val payload: Async = Uninitialized, val selectNetworkedAccountAsync: Async = Uninitialized, val selectedAccountId: String? = null, + val viewEffect: ViewEffect? = null ) : MavericksState { data class Payload( val title: String, val accounts: List >, + val dataAccessNotice: DataAccessNotice?, val addNewAccount: AddNewAccount, - val accessibleData: AccessibleDataCalloutModel, + val merchantDataAccess: MerchantDataAccessModel, val consumerSessionClientSecret: String, val defaultCta: String, val nextPaneOnNewAccount: Pane?, @@ -212,4 +237,19 @@ internal data class LinkAccountPickerState( ?.second?.selectionCta ?: payload.defaultCta } + + sealed class ViewEffect { + data class OpenUrl( + val url: String, + val id: Long + ) : ViewEffect() + + data class OpenBottomSheet( + val id: Long + ) : ViewEffect() + } +} + +internal enum class LinkAccountPickerClickableText(val value: String) { + DATA("stripe://data-access-notice"), } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationPreviewParameterProvider.kt new file mode 100644 index 00000000000..fb2bb75c8d3 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationPreviewParameterProvider.kt @@ -0,0 +1,65 @@ +package com.stripe.android.financialconnections.features.linkstepupverification + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.stripe.android.financialconnections.domain.ConfirmVerification +import com.stripe.android.uicore.elements.IdentifierSpec +import com.stripe.android.uicore.elements.OTPController +import com.stripe.android.uicore.elements.OTPElement + +internal class LinkStepUpVerificationPreviewParameterProvider : + PreviewParameterProvider { + override val values = sequenceOf( + loading(), + canonical(), + submitting(), + otpError(), + randomError() + ) + + private fun canonical() = LinkStepUpVerificationState( + payload = payload(), + confirmVerification = Uninitialized + ) + + private fun submitting() = LinkStepUpVerificationState( + payload = payload(), + confirmVerification = Loading() + ) + + private fun otpError() = LinkStepUpVerificationState( + payload = payload(), + confirmVerification = Fail( + ConfirmVerification.OTPError( + "12345678", + ConfirmVerification.OTPError.Type.EMAIL_CODE_EXPIRED + ) + ) + ) + + private fun randomError() = LinkStepUpVerificationState( + payload = payload(), + confirmVerification = Fail( + Exception("Random error") + ) + ) + + private fun payload() = Success( + LinkStepUpVerificationState.Payload( + email = "theLargestEmailYoulleverseeThatCouldBreakALayout@email.com", + phoneNumber = "12345678", + otpElement = OTPElement( + IdentifierSpec.Generic("otp"), + OTPController() + ), + consumerSessionClientSecret = "12345678" + ) + ) + + private fun loading() = LinkStepUpVerificationState( + payload = Loading(), + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationScreen.kt index 2ebd870d63b..71ea8ffa84c 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationScreen.kt @@ -4,17 +4,14 @@ package com.stripe.android.financialconnections.features.linkstepupverification import androidx.activity.compose.BackHandler import androidx.annotation.RestrictTo -import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -24,10 +21,9 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalTextInputService import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import com.airbnb.mvrx.Async import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Success @@ -35,7 +31,9 @@ import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.domain.ConfirmVerification.OTPError import com.stripe.android.financialconnections.features.common.FullScreenGenericLoading +import com.stripe.android.financialconnections.features.common.LoadingSpinner import com.stripe.android.financialconnections.features.common.UnclassifiedErrorContent import com.stripe.android.financialconnections.features.common.VerificationSection import com.stripe.android.financialconnections.features.linkstepupverification.LinkStepUpVerificationState.Payload @@ -47,11 +45,10 @@ import com.stripe.android.financialconnections.ui.components.AnnotatedText import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar import com.stripe.android.financialconnections.ui.components.StringAnnotation -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.components.elevation import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors -import com.stripe.android.uicore.elements.IdentifierSpec -import com.stripe.android.uicore.elements.OTPController -import com.stripe.android.uicore.elements.OTPElement +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography +import com.stripe.android.financialconnections.ui.theme.LazyLayout @Composable internal fun LinkStepUpVerificationScreen() { @@ -61,7 +58,7 @@ internal fun LinkStepUpVerificationScreen() { BackHandler(enabled = true) {} LinkStepUpVerificationContent( state = state.value, - onCloseClick = { parentViewModel.onCloseWithConfirmationClick(Pane.NETWORKING_LINK_SIGNUP_PANE) }, + onCloseClick = { parentViewModel.onCloseWithConfirmationClick(Pane.LINK_STEP_UP_VERIFICATION) }, onCloseFromErrorClick = parentViewModel::onCloseFromErrorClick, onClickableTextClick = viewModel::onClickableTextClick ) @@ -74,11 +71,12 @@ private fun LinkStepUpVerificationContent( onCloseFromErrorClick: (Throwable) -> Unit, onClickableTextClick: (String) -> Unit ) { - val scrollState = rememberScrollState() + val lazyListState = rememberLazyListState() FinancialConnectionsScaffold( topBar = { FinancialConnectionsTopAppBar( - showBack = false, + allowBackNavigation = false, + elevation = lazyListState.elevation, onCloseClick = onCloseClick ) } @@ -90,10 +88,11 @@ private fun LinkStepUpVerificationContent( onCloseFromErrorClick = onCloseFromErrorClick ) is Success -> LinkStepUpVerificationLoaded( - scrollState = scrollState, + lazyListState = lazyListState, + state.submitError, + state.submitLoading, payload = payload(), - confirmVerificationAsync = state.confirmVerification, - resendOtpAsync = state.resendOtp, + onCloseFromErrorClick = onCloseFromErrorClick, onClickableTextClick = onClickableTextClick ) } @@ -102,9 +101,10 @@ private fun LinkStepUpVerificationContent( @Composable private fun LinkStepUpVerificationLoaded( - confirmVerificationAsync: Async , - resendOtpAsync: Async , - scrollState: ScrollState, + lazyListState: LazyListState, + submitError: Throwable?, + submitLoading: Boolean, + onCloseFromErrorClick: (Throwable) -> Unit, payload: Payload, onClickableTextClick: (String) -> Unit ) { @@ -112,162 +112,99 @@ private fun LinkStepUpVerificationLoaded( val focusRequester: FocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } val textInputService = LocalTextInputService.current - LaunchedEffect(confirmVerificationAsync) { - if (confirmVerificationAsync is Loading) { + LaunchedEffect(submitLoading) { + if (submitLoading) { focusManager.clearFocus(true) @Suppress("DEPRECATION") textInputService?.hideSoftwareKeyboard() } } + if (submitError != null && submitError !is OTPError) { + UnclassifiedErrorContent( + error = submitError, + onCloseFromErrorClick = onCloseFromErrorClick + ) + } else { + LazyLayout( + verticalArrangement = Arrangement.spacedBy(24.dp), + lazyListState = lazyListState, + body = { + item { + HeaderSection(payload.email) + } + item { + VerificationSection( + focusRequester = focusRequester, + otpElement = payload.otpElement, + enabled = !submitLoading, + confirmVerificationError = submitError + ) + } + item { + ResendCodeSection( + isLoading = submitLoading, + onClickableTextClick = onClickableTextClick + ) + } + } + ) + } +} + +@Composable +private fun HeaderSection( + email: String, +) { Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding( - top = 0.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Spacer(modifier = Modifier.size(16.dp)) - Title() - Spacer(modifier = Modifier.size(8.dp)) - Description(email = payload.email) - Spacer(modifier = Modifier.size(24.dp)) - VerificationSection( - focusRequester = focusRequester, - otpElement = payload.otpElement, - enabled = confirmVerificationAsync !is Loading, - confirmVerificationError = (confirmVerificationAsync as? Fail)?.error + Text( + text = stringResource(R.string.stripe_link_stepup_verification_title), + style = typography.headingXLarge, ) - Spacer(modifier = Modifier.size(24.dp)) - EmailSubtext( - email = payload.email, - isLoading = resendOtpAsync is Loading, - onClickableTextClick = onClickableTextClick + Text( + text = stringResource(id = R.string.stripe_link_stepup_verification_desc, email), + style = typography.bodyMedium, ) } } @Composable -private fun EmailSubtext( - email: String, +private fun ResendCodeSection( isLoading: Boolean, onClickableTextClick: (String) -> Unit ) { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - listOf( - TextResource.Text(email) to 1f, - TextResource.Text("•") to null, - TextResource.StringId(R.string.stripe_link_stepup_verification_resend_code) to null - ).forEach { (text, weight) -> + if (isLoading) { + LoadingSpinner(modifier = Modifier.size(24.dp),) + } else { AnnotatedText( - modifier = if (weight != null) Modifier.weight(weight, fill = false) else Modifier, - text = text, + text = TextResource.StringId(R.string.stripe_link_stepup_verification_resend_code), maxLines = 1, - overflow = TextOverflow.Ellipsis, - defaultStyle = FinancialConnectionsTheme.typography.caption.copy( - color = colors.textSecondary, - ), + defaultStyle = typography.labelMedium, annotationStyles = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.captionEmphasized + StringAnnotation.CLICKABLE to typography.labelMediumEmphasized .toSpanStyle() - .copy(color = if (isLoading) colors.textSecondary else colors.textBrand), + .copy(color = colors.textBrand), ), onClickableTextClick = onClickableTextClick, ) } - if (isLoading) { - CircularProgressIndicator( - Modifier.size(12.dp), - strokeWidth = 1.dp, - color = colors.textSecondary - ) - } - } -} - -@Composable -private fun Description(email: String) { - AnnotatedText( - text = TextResource.Text( - stringResource( - R.string.stripe_link_stepup_verification_desc, - email - ) - ), - defaultStyle = FinancialConnectionsTheme.typography.body.copy( - color = colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.BOLD to FinancialConnectionsTheme.typography.bodyEmphasized - .toSpanStyle() - .copy(color = colors.textSecondary), - ), - onClickableTextClick = {}, - ) -} - -@Composable -private fun Title() { - AnnotatedText( - text = TextResource.Text( - stringResource(R.string.stripe_link_stepup_verification_title) - ), - defaultStyle = FinancialConnectionsTheme.typography.subtitle, - annotationStyles = emptyMap(), - onClickableTextClick = {}, - ) -} - -@Composable -@Preview(group = "LinkStepUpVerification Pane", name = "Canonical") -internal fun LinkStepUpVerificationScreenPreview() { - FinancialConnectionsPreview { - LinkStepUpVerificationContent( - state = LinkStepUpVerificationState( - payload = Success( - Payload( - email = "theLargestEmailYoulleverseeThatCouldBreakALayout@email.com", - phoneNumber = "12345678", - otpElement = OTPElement( - IdentifierSpec.Generic("otp"), - OTPController() - ), - consumerSessionClientSecret = "12345678" - ) - ) - ), - onCloseClick = {}, - onCloseFromErrorClick = {}, - onClickableTextClick = {} - ) } } @Composable -@Preview(group = "LinkStepUpVerification Pane", name = "Resending code") -internal fun LinkStepUpVerificationScreenResendingCodePreview() { +@Preview +internal fun LinkStepUpVerificationPreview( + @PreviewParameter(LinkStepUpVerificationPreviewParameterProvider::class) state: LinkStepUpVerificationState +) { FinancialConnectionsPreview { LinkStepUpVerificationContent( - state = LinkStepUpVerificationState( - resendOtp = Loading(), - payload = Success( - Payload( - email = "shortEmail@email.com", - phoneNumber = "12345678", - otpElement = OTPElement( - IdentifierSpec.Generic("otp"), - OTPController() - ), - consumerSessionClientSecret = "12345678" - ) - ) - ), + state = state, onCloseClick = {}, onCloseFromErrorClick = {}, onClickableTextClick = {} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationViewModel.kt index d0f1dceb817..64e308810be 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkstepupverification/LinkStepUpVerificationViewModel.kt @@ -37,6 +37,7 @@ import com.stripe.android.model.VerificationType import com.stripe.android.uicore.elements.IdentifierSpec import com.stripe.android.uicore.elements.OTPController import com.stripe.android.uicore.elements.OTPElement +import getRedactedPhoneNumber import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import javax.inject.Inject @@ -93,7 +94,7 @@ internal class LinkStepUpVerificationViewModel @Inject constructor( private fun buildPayload(consumerSession: ConsumerSession) = Payload( email = consumerSession.emailAddress, - phoneNumber = consumerSession.redactedPhoneNumber, + phoneNumber = consumerSession.getRedactedPhoneNumber(), consumerSessionClientSecret = consumerSession.clientSecret, otpElement = OTPElement( IdentifierSpec.Generic("otp"), @@ -118,6 +119,17 @@ internal class LinkStepUpVerificationViewModel @Inject constructor( ) }, ) + onAsync( + LinkStepUpVerificationState::confirmVerification, + onFail = { error -> + eventTracker.logError( + extraMessage = "Error confirming verification", + error = error, + logger = logger, + pane = PANE + ) + }, + ) } private fun onOTPEntered(otp: String) = suspend { @@ -218,6 +230,11 @@ internal data class LinkStepUpVerificationState( val resendOtp: Async = Uninitialized, ) : MavericksState { + val submitLoading: Boolean + get() = confirmVerification is Loading || resendOtp is Loading + val submitError: Throwable? + get() = (confirmVerification as? Fail)?.error ?: (resendOtp as? Fail)?.error + data class Payload( val email: String, val phoneNumber: String, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryInputValidator.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryInputValidator.kt index 6c0b517a824..3d46e24fdd0 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryInputValidator.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryInputValidator.kt @@ -1,23 +1,24 @@ package com.stripe.android.financialconnections.features.manualentry +import androidx.annotation.StringRes import com.stripe.android.financialconnections.R internal object ManualEntryInputValidator { - fun getRoutingErrorIdOrNull(input: String): Int? = when { + @StringRes fun getRoutingErrorIdOrNull(input: String): Int? = when { input.isEmpty() -> R.string.stripe_validation_routing_required input.length != ROUTING_NUMBER_LENGTH -> R.string.stripe_validation_routing_too_short input.isUSRoutingNumber().not() -> R.string.stripe_validation_no_us_routing else -> null } - fun getAccountErrorIdOrNull(input: String): Int? = when { + @StringRes fun getAccountErrorIdOrNull(input: String): Int? = when { input.isEmpty() -> R.string.stripe_validation_account_required input.length > ACCOUNT_NUMBER_MAX_LENGTH -> R.string.stripe_validation_account_too_long else -> null } - fun getAccountConfirmIdOrNull( + @StringRes fun getAccountConfirmIdOrNull( accountInput: String, accountConfirmInput: String ): Int? = when { @@ -26,7 +27,6 @@ internal object ManualEntryInputValidator { else -> null } - @Suppress("MagicNumber") private fun String.isUSRoutingNumber(): Boolean { val usRoutingFactor: (Int) -> Int = { when (it % 3) { diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryPreviewParameterProvider.kt index f26522699e4..60192644091 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryPreviewParameterProvider.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryPreviewParameterProvider.kt @@ -2,28 +2,45 @@ package com.stripe.android.financialconnections.features.manualentry import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized import com.stripe.android.core.exception.APIException +import com.stripe.android.financialconnections.R internal class ManualEntryPreviewParameterProvider : PreviewParameterProvider { override val values = sequenceOf( canonical(), + loading(), failure(), + fieldFailure(), + testMode(), ) override val count: Int get() = super.count + private fun loading() = ManualEntryState( + payload = Success( + ManualEntryState.Payload( + verifyWithMicrodeposits = true, + customManualEntry = false, + testMode = false + ) + ), + linkPaymentAccount = Loading(), + ) + private fun failure() = ManualEntryState( payload = Success( ManualEntryState.Payload( verifyWithMicrodeposits = true, - customManualEntry = false + customManualEntry = false, + testMode = false ) ), linkPaymentAccount = Fail( - APIException(message = "Error linking accounts") + APIException(message = "Test bank accounts cannot be used in live mode") ), ) @@ -31,9 +48,38 @@ internal class ManualEntryPreviewParameterProvider : PreviewParameterProvider = viewModel.collectAsState() + val state: ManualEntryState by viewModel.collectAsState() ManualEntryContent( - routing = state.value.routing to state.value.routingError, - account = state.value.account to state.value.accountError, - accountConfirm = state.value.accountConfirm to state.value.accountConfirmError, - isValidForm = state.value.isValidForm, - payload = state.value.payload, - linkPaymentAccountStatus = state.value.linkPaymentAccount, + routing = state.routing, + routingError = state.routingError, + account = state.account, + accountError = state.accountError, + accountConfirm = state.accountConfirm, + accountConfirmError = state.accountConfirmError, + isValidForm = state.isValidForm, + payload = state.payload, + linkPaymentAccountStatus = state.linkPaymentAccount, onRoutingEntered = viewModel::onRoutingEntered, onAccountEntered = viewModel::onAccountEntered, onAccountConfirmEntered = viewModel::onAccountConfirmEntered, onSubmit = viewModel::onSubmit, - ) { parentViewModel.onCloseWithConfirmationClick(Pane.MANUAL_ENTRY) } + onTestFill = viewModel::onTestFill, + onCloseClick = { parentViewModel.onCloseWithConfirmationClick(Pane.MANUAL_ENTRY) } + ) } @Composable private fun ManualEntryContent( - routing: Pair , - account: Pair , - accountConfirm: Pair , + routing: String, + routingError: Int?, + account: String, + accountError: Int?, + accountConfirm: String, + accountConfirmError: Int?, isValidForm: Boolean, payload: Async , linkPaymentAccountStatus: Async , @@ -89,7 +87,8 @@ private fun ManualEntryContent( onAccountEntered: (String) -> Unit, onAccountConfirmEntered: (String) -> Unit, onSubmit: () -> Unit, - onCloseClick: () -> Unit + onCloseClick: () -> Unit, + onTestFill: () -> Unit ) { val scrollState = rememberScrollState() FinancialConnectionsScaffold( @@ -114,13 +113,17 @@ private fun ManualEntryContent( linkPaymentAccountStatus = linkPaymentAccountStatus, payload = payload(), routing = routing, - onRoutingEntered = onRoutingEntered, + routingError = routingError, account = account, - onAccountEntered = onAccountEntered, + accountError = accountError, accountConfirm = accountConfirm, + accountConfirmError = accountConfirmError, + onRoutingEntered = onRoutingEntered, + onAccountEntered = onAccountEntered, onAccountConfirmEntered = onAccountConfirmEntered, isValidForm = isValidForm, - onSubmit = onSubmit + onSubmit = onSubmit, + onTestFill = onTestFill ) } } @@ -128,122 +131,151 @@ private fun ManualEntryContent( } @Composable -@Suppress("LongMethod") private fun ManualEntryLoaded( scrollState: ScrollState, payload: Payload, linkPaymentAccountStatus: Async , - routing: Pair , + routing: String, + routingError: Int?, + account: String, + accountError: Int?, + accountConfirm: String, + accountConfirmError: Int?, onRoutingEntered: (String) -> Unit, - account: Pair , onAccountEntered: (String) -> Unit, - accountConfirm: Pair , onAccountConfirmEntered: (String) -> Unit, isValidForm: Boolean, - onSubmit: () -> Unit + onSubmit: () -> Unit, + onTestFill: () -> Unit ) { - Column( - Modifier.fillMaxSize() - ) { - // Scrollable content - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(scrollState) - .padding( - top = 16.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) - ) { - var currentCheck: Int? by remember { mutableStateOf(R.drawable.stripe_check_base) } - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.stripe_manualentry_title), - color = FinancialConnectionsTheme.colors.textPrimary, - style = FinancialConnectionsTheme.typography.subtitle - ) - Spacer(modifier = Modifier.size(24.dp)) - Box { - Image( - painter = painterResource(id = R.drawable.stripe_check_base), - contentDescription = "Image of bank check referencing routing number" - ) - currentCheck?.let { - Image( - painter = painterResource(id = it), - contentDescription = "Image of bank check referencing routing number" - ) - } - } - if (linkPaymentAccountStatus is Fail) { - Text( - text = (linkPaymentAccountStatus.error as? StripeException)?.message - ?: stringResource(R.string.stripe_error_generic_title), - color = FinancialConnectionsTheme.colors.textCritical, - style = FinancialConnectionsTheme.typography.body - ) - Spacer(modifier = Modifier.size(8.dp)) - } + val loading = linkPaymentAccountStatus is Loading + Layout( + scrollState = scrollState, + body = { + Spacer(modifier = Modifier.size(8.dp)) + Title() + Spacer(modifier = Modifier.size(16.dp)) if (payload.verifyWithMicrodeposits) { Spacer(modifier = Modifier.size(8.dp)) Text( text = stringResource(R.string.stripe_manualentry_microdeposits_desc), - color = FinancialConnectionsTheme.colors.textPrimary, - style = FinancialConnectionsTheme.typography.body + color = FinancialConnectionsTheme.colors.textDefault, + style = FinancialConnectionsTheme.typography.bodyMedium + ) + } + if (payload.testMode) { + Spacer(modifier = Modifier.size(8.dp)) + TestModeBanner( + enabled = loading.not(), + buttonLabel = stringResource(id = R.string.stripe_manualentry_test_banner), + onButtonClick = onTestFill ) } - Spacer(modifier = Modifier.size(8.dp)) - - InputWithError( - label = R.string.stripe_manualentry_routing, - hint = "123456789", - inputWithError = routing, - testTag = "RoutingInput", - onInputChanged = onRoutingEntered, - onFocusGained = { currentCheck = R.drawable.stripe_check_routing } - ) Spacer(modifier = Modifier.size(24.dp)) - InputWithError( - label = R.string.stripe_manualentry_account, - hint = "000123456789", - inputWithError = account, - testTag = "AccountInput", - onInputChanged = onAccountEntered, - onFocusGained = { currentCheck = R.drawable.stripe_check_account } + AccountForm( + enabled = loading.not(), + routing = routing, + routingError = routingError, + onRoutingEntered = onRoutingEntered, + account = account, + accountError = accountError, + onAccountEntered = onAccountEntered, + accountConfirm = accountConfirm, + accountConfirmError = accountConfirmError, + onAccountConfirmEntered = onAccountConfirmEntered ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(R.string.stripe_manualentry_account_type_disclaimer), - color = FinancialConnectionsTheme.colors.textSecondary, - style = FinancialConnectionsTheme.typography.caption - ) - Spacer(modifier = Modifier.size(24.dp)) - InputWithError( - label = R.string.stripe_manualentry_accountconfirm, - hint = "000123456789", - inputWithError = accountConfirm, - testTag = "ConfirmAccountInput", - onInputChanged = onAccountConfirmEntered, - onFocusGained = { currentCheck = R.drawable.stripe_check_account } + if (linkPaymentAccountStatus is Fail) { + Spacer(modifier = Modifier.size(16.dp)) + ErrorMessage(linkPaymentAccountStatus.error) + } + }, + footer = { + ManualEntryFooter( + isValidForm = isValidForm, + loading = loading, + onSubmit = onSubmit ) - Spacer(modifier = Modifier.weight(1f)) } - // Footer - ManualEntryFooter(isValidForm, onSubmit) + ) +} + +@Composable +private fun ErrorMessage( + error: Throwable +) { + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = (error as? StripeException)?.message + ?: stringResource(R.string.stripe_error_generic_title), + style = FinancialConnectionsTheme.typography.bodyMedium, + color = FinancialConnectionsTheme.colors.textCritical, + ) +} + +@Composable +private fun Title() { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.stripe_manualentry_title), + color = FinancialConnectionsTheme.colors.textDefault, + style = FinancialConnectionsTheme.typography.headingXLarge + ) +} + +@Composable +private fun AccountForm( + enabled: Boolean, + routing: String, + routingError: Int?, + onRoutingEntered: (String) -> Unit, + account: String, + accountError: Int?, + onAccountEntered: (String) -> Unit, + accountConfirm: String, + accountConfirmError: Int?, + onAccountConfirmEntered: (String) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + InputWithError( + enabled = enabled, + label = R.string.stripe_manualentry_routing, + input = routing, + error = routingError, + testTag = "RoutingInput", + onInputChanged = onRoutingEntered, + ) + InputWithError( + enabled = enabled, + label = R.string.stripe_manualentry_account, + input = account, + error = accountError, + testTag = "AccountInput", + onInputChanged = onAccountEntered, + ) + InputWithError( + enabled = enabled, + label = R.string.stripe_manualentry_accountconfirm, + input = accountConfirm, + error = accountConfirmError, + testTag = "ConfirmAccountInput", + onInputChanged = onAccountConfirmEntered, + ) } } @Composable private fun ManualEntryFooter( isValidForm: Boolean, + loading: Boolean, onSubmit: () -> Unit ) { - Column( - modifier = Modifier.padding(24.dp) - ) { + Column { FinancialConnectionsButton( + loading = loading, enabled = isValidForm, onClick = onSubmit, modifier = Modifier @@ -257,49 +289,43 @@ private fun ManualEntryFooter( @OptIn(ExperimentalComposeUiApi::class) @Composable private fun InputWithError( - inputWithError: Pair , + enabled: Boolean, + input: String, + @StringRes error: Int?, label: Int, testTag: String, - hint: String, - onFocusGained: () -> Unit, onInputChanged: (String) -> Unit ) { - var textValue by remember { mutableStateOf(TextFieldValue()) } - Text( - text = stringResource(id = label), - color = FinancialConnectionsTheme.colors.textSecondary, - style = FinancialConnectionsTheme.typography.body - ) - Spacer(modifier = Modifier.size(4.dp)) - FinancialConnectionsOutlinedTextField( - value = textValue, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - ), - placeholder = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + FinancialConnectionsOutlinedTextField( + enabled = enabled, + value = input, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = stringResource(id = label), + style = FinancialConnectionsTheme.typography.labelLarge, + color = FinancialConnectionsTheme.colors.textSubdued + ) + }, + isError = error != null, + onValueChange = onInputChanged, + modifier = Modifier + .semantics { testTagsAsResourceId = true } + .testTag(testTag) + ) + if (error != null) { Text( - text = hint, - style = FinancialConnectionsTheme.typography.body, - color = FinancialConnectionsTheme.colors.textDisabled + text = stringResource(id = error), + color = FinancialConnectionsTheme.colors.textCritical, + style = FinancialConnectionsTheme.typography.labelSmall, ) - }, - isError = inputWithError.second != null, - onValueChange = { text -> - textValue = text.filtered { it.isDigit() } - onInputChanged(textValue.text) - }, - modifier = Modifier - .semantics { testTagsAsResourceId = true } - .testTag(testTag) - .onFocusChanged { if (it.isFocused) onFocusGained() } - ) - if (inputWithError.second != null) { - Text( - text = stringResource(id = inputWithError.second!!), - color = FinancialConnectionsTheme.colors.textCritical, - style = FinancialConnectionsTheme.typography.captionEmphasized, - modifier = Modifier.padding(start = 16.dp) - ) + } } } @@ -312,16 +338,21 @@ internal fun ManualEntryPreview( ) { FinancialConnectionsPreview { ManualEntryContent( - routing = "" to null, - account = "" to null, - accountConfirm = "" to null, + routing = state.routing, + routingError = state.routingError, + account = state.account, + accountError = state.accountError, + accountConfirm = state.accountConfirm, + accountConfirmError = state.accountConfirmError, isValidForm = true, payload = state.payload, linkPaymentAccountStatus = state.linkPaymentAccount, onRoutingEntered = {}, onAccountEntered = {}, onAccountConfirmEntered = {}, + onTestFill = {}, onSubmit = {}, - ) {} + onCloseClick = {} + ) } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryViewModel.kt index aa2339458c1..e5e8819d439 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryViewModel.kt @@ -24,7 +24,6 @@ import com.stripe.android.financialconnections.navigation.NavigationManager import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity import javax.inject.Inject -@Suppress("LongParameterList") internal class ManualEntryViewModel @Inject constructor( initialState: ManualEntryState, private val nativeAuthFlowCoordinator: NativeAuthFlowCoordinator, @@ -37,50 +36,20 @@ internal class ManualEntryViewModel @Inject constructor( init { observeAsyncs() - observeInputs() suspend { val sync = getOrFetchSync() val manifest = requireNotNull(sync.manifest) eventTracker.track(PaneLoaded(Pane.MANUAL_ENTRY)) ManualEntryState.Payload( verifyWithMicrodeposits = manifest.manualEntryUsesMicrodeposits, - customManualEntry = manifest.manualEntryMode == ManualEntryMode.CUSTOM + customManualEntry = manifest.manualEntryMode == ManualEntryMode.CUSTOM, + testMode = manifest.livemode.not() ) }.execute { copy(payload = it) } } - private fun observeInputs() { - onEach(ManualEntryState::accountConfirm) { input -> - if (input != null) { - withState { - val error: Int? = ManualEntryInputValidator.getAccountConfirmIdOrNull( - accountInput = it.account ?: "", - accountConfirmInput = input - ) - setState { copy(accountConfirmError = error) } - } - } - } - onEach(ManualEntryState::account) { input -> - if (input != null) { - setState { - val error = ManualEntryInputValidator.getAccountErrorIdOrNull(input) - copy(accountError = error) - } - } - } - onEach(ManualEntryState::routing) { input -> - if (input != null) { - setState { - val error = ManualEntryInputValidator.getRoutingErrorIdOrNull(input) - copy(routingError = error) - } - } - } - } - private fun observeAsyncs() { onAsync( ManualEntryState::payload, @@ -108,23 +77,32 @@ internal class ManualEntryViewModel @Inject constructor( } fun onRoutingEntered(input: String) { - val filteredInput = input.filter { it.isDigit() } - setState { copy(routing = filteredInput) } + setState { copy(routing = input.filter { it.isDigit() }) } + withState { + val error = ManualEntryInputValidator.getRoutingErrorIdOrNull(input) + setState { copy(routingError = error) } + } } fun onAccountEntered(input: String) { - val filteredInput = input.filter { it.isDigit() } - setState { copy(account = filteredInput) } + setState { copy(account = input.filter { it.isDigit() }) } + withState { + val error = ManualEntryInputValidator.getAccountErrorIdOrNull(input) + setState { copy(accountError = error) } + } } fun onAccountConfirmEntered(input: String) { - val filteredInput = input.filter { it.isDigit() } - setState { - copy(accountConfirm = filteredInput) + setState { copy(accountConfirm = input.filter { it.isDigit() }) } + withState { + val error: Int? = ManualEntryInputValidator.getAccountConfirmIdOrNull( + accountInput = it.account, + accountConfirmInput = input + ) + setState { copy(accountConfirmError = error) } } } - @Suppress("MagicNumber") fun onSubmit() { suspend { val state = awaitState() @@ -155,6 +133,20 @@ internal class ManualEntryViewModel @Inject constructor( }.execute { copy(linkPaymentAccount = it) } } + fun onTestFill() { + setState { + copy( + routing = "110000000", + account = "000123456789", + accountConfirm = "000123456789", + routingError = null, + accountError = null, + accountConfirmError = null + ) + } + onSubmit() + } + companion object : MavericksViewModelFactory { @@ -177,9 +169,9 @@ internal class ManualEntryViewModel @Inject constructor( internal data class ManualEntryState( val payload: Async = Uninitialized, - val routing: String? = null, - val account: String? = null, - val accountConfirm: String? = null, + val routing: String = "", + val account: String = "", + val accountConfirm: String = "", val routingError: Int? = null, val accountError: Int? = null, val accountConfirmError: Int? = null, @@ -188,7 +180,8 @@ internal data class ManualEntryState( data class Payload( val verifyWithMicrodeposits: Boolean, - val customManualEntry: Boolean + val customManualEntry: Boolean, + val testMode: Boolean ) val isValidForm @@ -197,5 +190,5 @@ internal data class ManualEntryState( (account to accountError).valid() && (accountConfirm to accountConfirmError).valid() - private fun Pair .valid() = first != null && second == null + private fun Pair .valid() = first.isNotEmpty() && second == null } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentrysuccess/ManualEntrySuccessScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentrysuccess/ManualEntrySuccessScreen.kt index a21d6f0651a..14bc430e24d 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentrysuccess/ManualEntrySuccessScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentrysuccess/ManualEntrySuccessScreen.kt @@ -1,355 +1,31 @@ -@file:Suppress("TooManyFunctions", "LongMethod") - package com.stripe.android.financialconnections.features.manualentrysuccess import androidx.activity.compose.BackHandler -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp +import androidx.compose.runtime.getValue import androidx.navigation.NavBackStackEntry -import com.airbnb.mvrx.Loading import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel -import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane -import com.stripe.android.financialconnections.model.LinkAccountSessionPaymentAccount.MicrodepositVerificationMethod -import com.stripe.android.financialconnections.navigation.Destination +import com.stripe.android.financialconnections.features.success.SuccessContent +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest import com.stripe.android.financialconnections.presentation.parentViewModel -import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview -import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton -import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold -import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme @Composable internal fun ManualEntrySuccessScreen( backStackEntry: NavBackStackEntry, ) { val parentViewModel = parentViewModel() - val viewModel: ManualEntrySuccessViewModel = mavericksViewModel() + val viewModel: ManualEntrySuccessViewModel = mavericksViewModel(argsFactory = { backStackEntry.arguments }) + val state by viewModel.collectAsState() BackHandler(true) {} - val completeAuthSessionAsync = viewModel - .collectAsState(ManualEntrySuccessState::completeSession) - ManualEntrySuccessContent( - microdepositVerificationMethod = Destination.ManualEntrySuccess.microdeposits(backStackEntry), - last4 = Destination.ManualEntrySuccess.last4(backStackEntry), - loading = completeAuthSessionAsync.value is Loading, - onCloseClick = { parentViewModel.onCloseNoConfirmationClick(Pane.MANUAL_ENTRY_SUCCESS) }, - onDoneClick = viewModel::onSubmit - ) -} - -@Composable -internal fun ManualEntrySuccessContent( - microdepositVerificationMethod: MicrodepositVerificationMethod, - last4: String?, - loading: Boolean, - onCloseClick: () -> Unit, - onDoneClick: () -> Unit -) { - FinancialConnectionsScaffold( - topBar = { - FinancialConnectionsTopAppBar( - showBack = false, - onCloseClick = onCloseClick, + SuccessContent( + completeSessionAsync = state.completeSession, + payloadAsync = state.payload, + onDoneClick = viewModel::onSubmit, + onCloseClick = { + parentViewModel.onCloseNoConfirmationClick( + FinancialConnectionsSessionManifest.Pane.MANUAL_ENTRY_SUCCESS ) } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxSize() - .padding( - top = 8.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) - ) { - Icon( - modifier = Modifier.size(40.dp), - painter = painterResource(R.drawable.stripe_ic_check_circle), - contentDescription = null, - tint = FinancialConnectionsTheme.colors.textSuccess - ) - Text( - modifier = Modifier - .fillMaxWidth(), - text = when (microdepositVerificationMethod) { - MicrodepositVerificationMethod.UNKNOWN, - MicrodepositVerificationMethod.AMOUNTS -> - stringResource(R.string.stripe_manualentrysuccess_title) - MicrodepositVerificationMethod.DESCRIPTOR_CODE -> - stringResource(R.string.stripe_manualentrysuccess_title_descriptorcode) - }, - style = FinancialConnectionsTheme.typography.subtitle.copy( - color = FinancialConnectionsTheme.colors.textPrimary - ) - ) - Text( - text = resolveText(microdepositVerificationMethod, last4), - style = FinancialConnectionsTheme.typography.body.copy( - color = FinancialConnectionsTheme.colors.textSecondary - ) - ) - Spacer(modifier = Modifier.height(8.dp)) - TransactionHistoryTable( - microdepositVerificationMethod = microdepositVerificationMethod, - last4 = last4 - ) - Spacer(modifier = Modifier.weight(1f)) - FinancialConnectionsButton( - loading = loading, - onClick = onDoneClick, - modifier = Modifier - .fillMaxWidth() - ) { - Text(text = stringResource(R.string.stripe_success_pane_done)) - } - } - } -} - -@Composable -internal fun resolveText( - microdepositVerificationMethod: MicrodepositVerificationMethod, - last4: String? -) = when (microdepositVerificationMethod) { - MicrodepositVerificationMethod.AMOUNTS -> when { - last4 != null -> stringResource(R.string.stripe_manualentrysuccess_desc, last4) - else -> stringResource(R.string.stripe_manualentrysuccess_desc_noaccount) - } - - MicrodepositVerificationMethod.DESCRIPTOR_CODE -> when { - last4 != null -> stringResource( - R.string.stripe_manualentrysuccess_desc_descriptorcode, - last4 - ) - - else -> stringResource(R.string.stripe_manualentrysuccess_desc_noaccount_descriptorcode) - } - - MicrodepositVerificationMethod.UNKNOWN -> TODO() -} - -@Composable -internal fun TransactionHistoryTable( - last4: String?, - microdepositVerificationMethod: MicrodepositVerificationMethod -) { - val shape = RoundedCornerShape(8.dp) - Box( - Modifier - .clip(shape) - .background(FinancialConnectionsTheme.colors.backgroundContainer) - .border( - border = BorderStroke(1.dp, FinancialConnectionsTheme.colors.borderDefault), - shape = shape - ) - ) { - Column( - Modifier - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - ) { - val titleColor = FinancialConnectionsTheme.colors.textSecondary - val tableData = - buildTableRows(microdepositVerificationMethod) - last4?.let { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.stripe_ic_bank), - tint = FinancialConnectionsTheme.colors.textSecondary, - contentDescription = "Bank icon" - ) - Text( - text = stringResource(R.string.stripe_manualentrysuccess_table_title, it), - style = FinancialConnectionsTheme.typography.bodyCode.copy(color = titleColor) - ) - } - Spacer(Modifier.size(8.dp)) - } - Row { - TitleCell(text = "Transaction") - TitleCell(text = "Amount") - TitleCell(text = "Type") - } - Divider( - modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), - color = FinancialConnectionsTheme.colors.borderDefault - ) - tableData.withIndex().forEach { (index, item) -> - val (transaction, amount, type) = item - val highlight = tableData.lastIndex != index - Row(Modifier.fillMaxWidth()) { - TableCell(text = transaction.first, transaction.second, highlight) - TableCell(text = amount.first, amount.second, highlight) - TableCell(text = type.first, type.second, highlight) - } - } - } - // Bottom fade. - Box( - modifier = Modifier - .fillMaxWidth() - .height(26.dp) - .align(Alignment.BottomCenter) - .background( - brush = Brush.verticalGradient( - colors = listOf( - FinancialConnectionsTheme.colors.textWhite.copy(alpha = 0f), - FinancialConnectionsTheme.colors.textWhite.copy(alpha = 1f) - ) - ) - ) - ) - } -} - -@Composable -private fun buildTableRows( - microdepositVerificationMethod: MicrodepositVerificationMethod -): List , Pair , Pair >> { - val rowColor = FinancialConnectionsTheme.colors.textPrimary - val highlightColor = FinancialConnectionsTheme.colors.textBrand - return when (microdepositVerificationMethod) { - MicrodepositVerificationMethod.DESCRIPTOR_CODE -> listOf( - Triple("SMXXXX" to highlightColor, "$0.01" to rowColor, "ACH CREDIT" to rowColor), - Triple("GROCERIES" to rowColor, "$56.12" to rowColor, "VISA" to rowColor) - ) - - MicrodepositVerificationMethod.AMOUNTS -> listOf( - Triple("AMTS" to rowColor, "$0.XX" to highlightColor, "ACH CREDIT" to rowColor), - Triple("AMTS" to rowColor, "$0.XX" to highlightColor, "ACH CREDIT" to rowColor), - Triple("GROCERIES" to rowColor, "$56.12" to rowColor, "VISA" to rowColor) - ) - - MicrodepositVerificationMethod.UNKNOWN -> error("Unknown microdeposits type") - } -} - -@Composable -private fun RowScope.TitleCell( - text: String, -) { - Text( - text = text, - style = FinancialConnectionsTheme.typography.caption.copy( - color = FinancialConnectionsTheme.colors.textSecondary - ), - modifier = Modifier - .padding(vertical = 4.dp) - .weight(1f) - ) -} - -@Composable -private fun RowScope.TableCell( - text: String, - color: Color, - highlight: Boolean -) { - val typography = if (highlight) { - FinancialConnectionsTheme.typography.captionCodeEmphasized - } else { - FinancialConnectionsTheme.typography.captionCode - } - Text( - text = text, - style = typography.copy(color = color), - modifier = Modifier - .padding(vertical = 4.dp) - .weight(1f) ) } - -@Preview( - group = "Manual Entry Success", - name = "Amount" -) -@Composable -internal fun ManualEntrySuccessScreenPreviewAmount() { - FinancialConnectionsPreview { - ManualEntrySuccessContent( - MicrodepositVerificationMethod.AMOUNTS, - last4 = "1234", - loading = false, - onCloseClick = {} - ) {} - } -} - -@Preview( - group = "Manual Entry Success", - name = "Descriptor" -) -@Composable -internal fun ManualEntrySuccessDescriptor() { - FinancialConnectionsPreview { - ManualEntrySuccessContent( - MicrodepositVerificationMethod.DESCRIPTOR_CODE, - last4 = "1234", - loading = false, - onCloseClick = {} - ) {} - } -} - -@Preview( - group = "Manual Entry Success", - name = "Amount no account" -) -@Composable -internal fun ManualEntrySuccessAmountNoAccount() { - FinancialConnectionsPreview { - ManualEntrySuccessContent( - MicrodepositVerificationMethod.AMOUNTS, - last4 = null, - loading = false, - onCloseClick = {} - ) {} - } -} - -@Preview( - group = "Manual Entry Success", - name = "Descriptor no account" -) -@Composable -internal fun ManualEntrySuccessDescriptorNoAccount() { - FinancialConnectionsPreview { - ManualEntrySuccessContent( - MicrodepositVerificationMethod.DESCRIPTOR_CODE, - last4 = null, - loading = false, - onCloseClick = {} - ) {} - } -} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentrysuccess/ManualEntrySuccessViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentrysuccess/ManualEntrySuccessViewModel.kt index 17c49880077..3721b78831f 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentrysuccess/ManualEntrySuccessViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/manualentrysuccess/ManualEntrySuccessViewModel.kt @@ -1,5 +1,6 @@ package com.stripe.android.financialconnections.features.manualentrysuccess +import android.os.Bundle import com.airbnb.mvrx.Async import com.airbnb.mvrx.Loading import com.airbnb.mvrx.MavericksState @@ -7,27 +8,43 @@ import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext +import com.stripe.android.financialconnections.R import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.ClickDone import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator +import com.stripe.android.financialconnections.features.success.SuccessState import com.stripe.android.financialconnections.model.FinancialConnectionsSession import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane +import com.stripe.android.financialconnections.navigation.Destination import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity +import com.stripe.android.financialconnections.ui.TextResource.StringId import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") internal class ManualEntrySuccessViewModel @Inject constructor( initialState: ManualEntrySuccessState, + private val getManifest: GetManifest, private val eventTracker: FinancialConnectionsAnalyticsTracker, private val nativeAuthFlowCoordinator: NativeAuthFlowCoordinator, ) : MavericksViewModel (initialState) { init { - viewModelScope.launch { - eventTracker.track(PaneLoaded(Pane.MANUAL_ENTRY_SUCCESS)) - } + suspend { + val manifest = getManifest() + SuccessState.Payload( + businessName = manifest.businessName, + customSuccessMessage = when (val last4 = initialState.last4) { + null -> StringId(R.string.stripe_success_pane_desc_microdeposits_no_account) + else -> StringId(R.string.stripe_success_pane_desc_microdeposits, listOf(last4)) + }, + accountsCount = 1, // on manual entry just one account is connected, + skipSuccessPane = false + ).also { + eventTracker.track(PaneLoaded(Pane.MANUAL_ENTRY_SUCCESS)) + } + }.execute { copy(payload = it) } } fun onSubmit() { @@ -57,5 +74,15 @@ internal class ManualEntrySuccessViewModel @Inject constructor( } internal data class ManualEntrySuccessState( - val completeSession: Async = Uninitialized -) : MavericksState + val last4: String?, + val payload: Async , + val completeSession: Async +) : MavericksState { + + @Suppress("unused") // used by mavericks to create initial state. + constructor(args: Bundle?) : this( + last4 = Destination.ManualEntrySuccess.last4(args), + payload = Uninitialized, + completeSession = Uninitialized + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupPreviewParameterProvider.kt new file mode 100644 index 00000000000..f5b2cc3c40d --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupPreviewParameterProvider.kt @@ -0,0 +1,58 @@ +package com.stripe.android.financialconnections.features.networkinglinkloginwarmup + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized + +internal class NetworkingLinkLoginWarmupPreviewParameterProvider : + PreviewParameterProvider { + override val values = sequenceOf( + canonical(), + loading(), + disablingNetworking(), + payloadError(), + disablingError() + ) + + private fun canonical() = NetworkingLinkLoginWarmupState( + payload = Success( + NetworkingLinkLoginWarmupState.Payload( + merchantName = "Test", + email = "email@test.com" + ) + ), + disableNetworkingAsync = Uninitialized + ) + + private fun loading() = NetworkingLinkLoginWarmupState( + payload = Loading(), + disableNetworkingAsync = Uninitialized + ) + + private fun payloadError() = NetworkingLinkLoginWarmupState( + payload = Fail(Exception("Error")), + disableNetworkingAsync = Uninitialized + ) + + private fun disablingError() = NetworkingLinkLoginWarmupState( + payload = Success( + NetworkingLinkLoginWarmupState.Payload( + merchantName = "Test", + email = "email@test.com" + ) + ), + disableNetworkingAsync = Fail(Exception("Error")) + ) + + private fun disablingNetworking() = NetworkingLinkLoginWarmupState( + payload = Success( + NetworkingLinkLoginWarmupState.Payload( + merchantName = "Test", + email = "email@test.com" + ) + ), + disableNetworkingAsync = Loading() + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupScreen.kt index a35db60f1f2..4fecc27de16 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupScreen.kt @@ -3,23 +3,22 @@ package com.stripe.android.financialconnections.features.networkinglinkloginwarmup import androidx.annotation.RestrictTo -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -31,37 +30,33 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import com.airbnb.mvrx.Fail +import androidx.navigation.NavBackStackEntry import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.Success -import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.features.common.UnclassifiedErrorContent -import com.stripe.android.financialconnections.features.networkinglinkloginwarmup.NetworkingLinkLoginWarmupState.Payload -import com.stripe.android.financialconnections.features.networkinglinkloginwarmup.NetworkingLinkLoginWarmupViewModel.Companion.PANE -import com.stripe.android.financialconnections.presentation.parentViewModel +import com.stripe.android.financialconnections.features.common.ShapedIcon import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview -import com.stripe.android.financialconnections.ui.TextResource.StringId -import com.stripe.android.financialconnections.ui.components.AnnotatedText -import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold -import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar -import com.stripe.android.financialconnections.ui.components.StringAnnotation -import com.stripe.android.financialconnections.ui.components.clickableSingle -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton.Type +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography +import com.stripe.android.financialconnections.ui.theme.LazyLayout +import com.stripe.android.financialconnections.ui.theme.LinkColors @Composable -internal fun NetworkingLinkLoginWarmupScreen() { - val viewModel: NetworkingLinkLoginWarmupViewModel = mavericksViewModel() - val parentViewModel = parentViewModel() - val state = viewModel.collectAsState() +internal fun NetworkingLinkLoginWarmupScreen( + backStackEntry: NavBackStackEntry, +) { + val viewModel: NetworkingLinkLoginWarmupViewModel = mavericksViewModel( + argsFactory = { backStackEntry.arguments }, + ) + val state by viewModel.collectAsState() NetworkingLinkLoginWarmupContent( - state = state.value, - onCloseClick = { parentViewModel.onCloseWithConfirmationClick(PANE) }, - onCloseFromErrorClick = parentViewModel::onCloseFromErrorClick, - onClickableTextClick = viewModel::onClickableTextClick, + state = state, + onSkipClicked = viewModel::onSkipClicked, onContinueClick = viewModel::onContinueClick, ) } @@ -69,197 +64,140 @@ internal fun NetworkingLinkLoginWarmupScreen() { @Composable private fun NetworkingLinkLoginWarmupContent( state: NetworkingLinkLoginWarmupState, - onCloseClick: () -> Unit, onContinueClick: () -> Unit, - onCloseFromErrorClick: (Throwable) -> Unit, - onClickableTextClick: (String) -> Unit, + onSkipClicked: () -> Unit, ) { - val scrollState = rememberScrollState() - FinancialConnectionsScaffold( - topBar = { - FinancialConnectionsTopAppBar( - showBack = true, - onCloseClick = onCloseClick - ) - } - ) { - when (val payload = state.payload) { - Uninitialized, is Loading -> NetworkingLinkLoginWarmupLoading() - is Success -> when (val disableNetworking = state.disableNetworkingAsync) { - is Loading -> NetworkingLinkLoginWarmupLoading() - is Uninitialized, - is Success -> NetworkingLinkLoginWarmupLoaded( - scrollState = scrollState, - payload = payload(), - onClickableTextClick = onClickableTextClick, - onContinueClick = onContinueClick - ) - - is Fail -> UnclassifiedErrorContent( - error = disableNetworking.error, - onCloseFromErrorClick = onCloseFromErrorClick - ) - } - - is Fail -> UnclassifiedErrorContent( - error = payload.error, - onCloseFromErrorClick = onCloseFromErrorClick + val lazyListState = rememberLazyListState() + LazyLayout( + modifier = Modifier + .fillMaxWidth() + .background(color = colors.backgroundSurface) + .padding(top = 24.dp), + inModal = true, + verticalArrangement = Arrangement.spacedBy(24.dp), + lazyListState = lazyListState, + body = { + item { HeaderSection() } + item { ExistingEmailSection(email = state.payload()?.email ?: "") } + }, + footer = { + Footer( + loading = state.disableNetworkingAsync is Loading || state.payload() == null, + onContinueClick = onContinueClick, + onSkipClicked = onSkipClicked ) } - } + ) } @Composable -private fun NetworkingLinkLoginWarmupLoading() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator( - color = FinancialConnectionsTheme.colors.iconBrand +private fun HeaderSection() { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ShapedIcon( + painter = painterResource(id = R.drawable.stripe_ic_person), + contentDescription = stringResource(R.string.stripe_networking_link_login_warmup_title) + ) + Text( + text = stringResource(R.string.stripe_networking_link_login_warmup_title), + style = typography.headingLarge, + ) + Text( + text = stringResource(R.string.stripe_networking_link_login_warmup_description), + style = typography.bodyMedium, ) } } @Composable -private fun NetworkingLinkLoginWarmupLoaded( - scrollState: ScrollState, - payload: Payload, - onClickableTextClick: (String) -> Unit, +@OptIn(ExperimentalComposeUiApi::class) +private fun Footer( + loading: Boolean, onContinueClick: () -> Unit, + onSkipClicked: () -> Unit ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding( - top = 0.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) - ) { + Column { + FinancialConnectionsButton( + loading = false, + enabled = loading.not(), + type = Type.Primary, + onClick = onContinueClick, + modifier = Modifier + .semantics { testTagsAsResourceId = true } + .testTag("existing_email-button") + .fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.stripe_networking_link_login_warmup_cta_continue)) + } Spacer(modifier = Modifier.size(16.dp)) - Title( - onClickableTextClick = onClickableTextClick - ) - Spacer(modifier = Modifier.size(8.dp)) - Description(onClickableTextClick) - Spacer(modifier = Modifier.size(24.dp)) - ExistingEmailSection( - email = payload.email, - onContinueClick = onContinueClick - ) - Spacer(modifier = Modifier.size(20.dp)) - SkipSignIn(onClickableTextClick) + FinancialConnectionsButton( + loading = false, + enabled = loading.not(), + type = Type.Secondary, + onClick = { onSkipClicked() }, + modifier = Modifier + .semantics { testTagsAsResourceId = true } + .testTag("skip-button") + .fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.stripe_networking_link_login_warmup_cta_skip)) + } } } -@Composable -private fun SkipSignIn(onClickableTextClick: (String) -> Unit) { - AnnotatedText( - text = StringId(R.string.stripe_networking_link_login_warmup_skip), - defaultStyle = FinancialConnectionsTheme.typography.caption.copy( - color = FinancialConnectionsTheme.colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.captionEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textBrand), - ), - onClickableTextClick = onClickableTextClick, - ) -} - -@Composable -private fun Description(onClickableTextClick: (String) -> Unit) { - AnnotatedText( - text = StringId(R.string.stripe_networking_link_login_warmup_description), - defaultStyle = FinancialConnectionsTheme.typography.body.copy( - color = FinancialConnectionsTheme.colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.body - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textBrand), - ), - onClickableTextClick = onClickableTextClick, - ) -} - -@Composable -private fun Title(onClickableTextClick: (String) -> Unit) { - AnnotatedText( - text = StringId(R.string.stripe_networking_link_login_warmup_title), - defaultStyle = FinancialConnectionsTheme.typography.subtitle, - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.subtitle - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textBrand), - ), - onClickableTextClick = onClickableTextClick, - ) -} - @OptIn(ExperimentalComposeUiApi::class) @Composable internal fun ExistingEmailSection( - email: String, - onContinueClick: () -> Unit + email: String ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .semantics { testTagsAsResourceId = true } - .testTag("existing_email-button") - .clickableSingle { onContinueClick() } - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(12.dp)) .border( width = 1.dp, - color = FinancialConnectionsTheme.colors.borderDefault, - shape = RoundedCornerShape(8.dp) + color = colors.border, + shape = RoundedCornerShape(12.dp) ) - .padding(12.dp) + .padding(horizontal = 16.dp, vertical = 12.dp) ) { - Column( - Modifier.weight(1f) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(32.dp) + .background(color = LinkColors.Brand200, shape = CircleShape) ) { Text( - text = stringResource(id = R.string.stripe_networking_link_login_warmup_email_label), - style = FinancialConnectionsTheme.typography.caption, - color = FinancialConnectionsTheme.colors.textSecondary - ) - Text( - text = email, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = FinancialConnectionsTheme.typography.body, - color = FinancialConnectionsTheme.colors.textPrimary + text = email.getOrElse(0) { '@' }.uppercaseChar().toString(), + style = typography.bodySmall, + color = LinkColors.Brand600 ) } - Icon( - painter = painterResource(id = R.drawable.stripe_ic_arrow_right_circle), - tint = FinancialConnectionsTheme.colors.textBrand, - contentDescription = null + Spacer(modifier = Modifier.size(12.dp)) + Text( + modifier = Modifier.weight(1f), + text = email, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = typography.bodySmall, + color = colors.textDefault ) } } @Composable @Preview(group = "NetworkingLinkLoginWarmup Pane", name = "Canonical") -internal fun NetworkingLinkLoginWarmupScreenEnteringEmailPreview() { +internal fun NetworkingLinkLoginWarmupScreenPreview( + @PreviewParameter(NetworkingLinkLoginWarmupPreviewParameterProvider::class) state: NetworkingLinkLoginWarmupState +) { FinancialConnectionsPreview { NetworkingLinkLoginWarmupContent( - state = NetworkingLinkLoginWarmupState( - payload = Success( - Payload( - merchantName = "Test", - email = "verylongemailthatshouldellipsize@gmail.com", - ) - ), - ), - onCloseClick = {}, - onClickableTextClick = {}, + state = state, onContinueClick = {}, - onCloseFromErrorClick = {} + onSkipClicked = {}, ) } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupViewModel.kt index 1a53efbae21..30be9b816aa 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupViewModel.kt @@ -1,24 +1,25 @@ package com.stripe.android.financialconnections.features.networkinglinkloginwarmup +import android.os.Bundle import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext -import com.stripe.android.core.Logger import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.Click import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker -import com.stripe.android.financialconnections.analytics.logError import com.stripe.android.financialconnections.domain.DisableNetworking import com.stripe.android.financialconnections.domain.GetManifest +import com.stripe.android.financialconnections.domain.HandleError import com.stripe.android.financialconnections.features.common.getBusinessName import com.stripe.android.financialconnections.features.common.getRedactedEmail import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.navigation.Destination import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.navigation.PopUpToBehavior import com.stripe.android.financialconnections.navigation.destination import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity import kotlinx.coroutines.launch @@ -27,10 +28,10 @@ import javax.inject.Inject internal class NetworkingLinkLoginWarmupViewModel @Inject constructor( initialState: NetworkingLinkLoginWarmupState, private val eventTracker: FinancialConnectionsAnalyticsTracker, + private val handleError: HandleError, private val getManifest: GetManifest, private val disableNetworking: DisableNetworking, - private val navigationManager: NavigationManager, - private val logger: Logger + private val navigationManager: NavigationManager ) : MavericksViewModel (initialState) { init { @@ -49,21 +50,21 @@ internal class NetworkingLinkLoginWarmupViewModel @Inject constructor( onAsync( NetworkingLinkLoginWarmupState::payload, onFail = { error -> - eventTracker.logError( + handleError( extraMessage = "Error fetching payload", error = error, - logger = logger, - pane = PANE + pane = PANE, + displayErrorScreen = true ) }, ) onAsync( NetworkingLinkLoginWarmupState::disableNetworkingAsync, onFail = { error -> - eventTracker.logError( + handleError( extraMessage = "Error disabling networking", error = error, - logger = logger, + displayErrorScreen = true, pane = PANE ) }, @@ -75,34 +76,41 @@ internal class NetworkingLinkLoginWarmupViewModel @Inject constructor( navigationManager.tryNavigateTo(Destination.NetworkingLinkVerification(referrer = PANE)) } - fun onClickableTextClick(text: String) = when (text) { - CLICKABLE_TEXT_SKIP_LOGIN -> onSkipClicked() - else -> logger.error("Unknown clicked text $text") - } - - private fun onSkipClicked() { + fun onSkipClicked() { suspend { eventTracker.track(Click("click.skip_sign_in", PANE)) disableNetworking().also { + val popUpToBehavior = determinePopUpToBehaviorForSkip() navigationManager.tryNavigateTo( - // skipping disables networking, which means - // we don't want the user to navigate back to - // the warm-up pane. - it.nextPane.destination(referrer = PANE), - popUpToCurrent = true, - inclusive = true + route = it.nextPane.destination(referrer = PANE), + popUpTo = popUpToBehavior, ) } }.execute { copy(disableNetworkingAsync = it) } } + private suspend fun determinePopUpToBehaviorForSkip(): PopUpToBehavior { + // Skipping disables networking, which means we don't want the user to navigate back to + // the warm-up pane. Since the warmup pane is displayed as a bottom sheet, we need to + // pop up all the way to the pane that opened it. + val referrer = awaitState().referrer + + return if (referrer != null) { + PopUpToBehavior.Route( + route = referrer.destination.fullRoute, + inclusive = true, + ) + } else { + // Let's give it our best shot even though we don't know the referrer + PopUpToBehavior.Current(inclusive = true) + } + } + companion object : MavericksViewModelFactory { internal val PANE = Pane.NETWORKING_LINK_LOGIN_WARMUP - private const val CLICKABLE_TEXT_SKIP_LOGIN = "skip_login" - override fun create( viewModelContext: ViewModelContext, state: NetworkingLinkLoginWarmupState @@ -119,10 +127,18 @@ internal class NetworkingLinkLoginWarmupViewModel @Inject constructor( } internal data class NetworkingLinkLoginWarmupState( + val referrer: Pane? = null, val payload: Async = Uninitialized, val disableNetworkingAsync: Async = Uninitialized, ) : MavericksState { + @Suppress("unused") // used by mavericks to create initial state. + constructor(args: Bundle?) : this( + referrer = Destination.referrer(args), + payload = Uninitialized, + disableNetworkingAsync = Uninitialized, + ) + data class Payload( val merchantName: String?, val email: String diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupPreviewParameterProvider.kt index 5ed031aa379..e40f4e89a7d 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupPreviewParameterProvider.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupPreviewParameterProvider.kt @@ -14,54 +14,75 @@ internal class NetworkingLinkSignupPreviewParameterProvider : PreviewParameterProvider { override val values = sequenceOf( default(), - emailEntered() + emailEntered(), + invalidEmail() ) - internal fun default(): NetworkingLinkSignupState { - return NetworkingLinkSignupState( - payload = Success( - NetworkingLinkSignupState.Payload( - merchantName = "Test", - emailController = EmailConfig.createController(""), - phoneController = PhoneNumberController.createPhoneNumberController( - initialValue = "", - initiallySelectedCountryCode = null, - ), - content = networkingLinkSignupPane() - ) - ), - validEmail = null, - validPhone = null, - lookupAccount = Uninitialized, - saveAccountToLink = Uninitialized - ) - } + private fun default() = NetworkingLinkSignupState( + payload = Success( + NetworkingLinkSignupState.Payload( + merchantName = "Test", + emailController = EmailConfig.createController(""), + phoneController = PhoneNumberController.createPhoneNumberController( + initialValue = "", + initiallySelectedCountryCode = null, + ), + content = networkingLinkSignupPane() + ) + ), + validEmail = null, + validPhone = null, + lookupAccount = Uninitialized, + saveAccountToLink = Uninitialized + ) - private fun emailEntered(): NetworkingLinkSignupState { - return NetworkingLinkSignupState( - payload = Success( - NetworkingLinkSignupState.Payload( - merchantName = "Test", - emailController = EmailConfig.createController("email"), - phoneController = PhoneNumberController.createPhoneNumberController( - initialValue = "", - initiallySelectedCountryCode = null, - ), - content = networkingLinkSignupPane() - ) - ), - validEmail = "test@test.com", - validPhone = null, - lookupAccount = Success( - ConsumerSessionLookup( - exists = false, - consumerSession = null, - errorMessage = null - ) - ), - saveAccountToLink = Uninitialized - ) - } + private fun emailEntered() = NetworkingLinkSignupState( + payload = Success( + NetworkingLinkSignupState.Payload( + merchantName = "Test", + emailController = EmailConfig.createController("valid@email.com"), + phoneController = PhoneNumberController.createPhoneNumberController( + initialValue = "", + initiallySelectedCountryCode = null, + ), + content = networkingLinkSignupPane() + ) + ), + validEmail = "test@test.com", + validPhone = null, + lookupAccount = Success( + ConsumerSessionLookup( + exists = false, + consumerSession = null, + errorMessage = null + ) + ), + saveAccountToLink = Uninitialized + ) + + private fun invalidEmail() = NetworkingLinkSignupState( + payload = Success( + NetworkingLinkSignupState.Payload( + merchantName = "Test", + emailController = EmailConfig.createController("invalid_email.com"), + phoneController = PhoneNumberController.createPhoneNumberController( + initialValue = "", + initiallySelectedCountryCode = null, + ), + content = networkingLinkSignupPane() + ) + ), + validEmail = "test@test.com", + validPhone = null, + lookupAccount = Success( + ConsumerSessionLookup( + exists = false, + consumerSession = null, + errorMessage = null + ) + ), + saveAccountToLink = Uninitialized + ) private fun networkingLinkSignupPane() = NetworkingLinkSignupPane( aboveCta = "By saving your account to Link, you agree to Link’s Terms and Privacy Policy", diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupScreen.kt index 5be3feec45f..d70ed75165a 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupScreen.kt @@ -1,24 +1,38 @@ package com.stripe.android.financialconnections.features.networkinglinksignup import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.tween import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Text +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics @@ -35,11 +49,12 @@ import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel -import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.features.common.BulletItem import com.stripe.android.financialconnections.features.common.FullScreenGenericLoading +import com.stripe.android.financialconnections.features.common.LegalDetailsBottomSheetContent +import com.stripe.android.financialconnections.features.common.ListItem import com.stripe.android.financialconnections.features.common.UnclassifiedErrorContent import com.stripe.android.financialconnections.features.networkinglinksignup.NetworkingLinkSignupState.Payload +import com.stripe.android.financialconnections.features.networkinglinksignup.NetworkingLinkSignupState.ViewEffect import com.stripe.android.financialconnections.features.networkinglinksignup.NetworkingLinkSignupState.ViewEffect.OpenUrl import com.stripe.android.financialconnections.features.networkinglinksignup.NetworkingLinkSignupViewModel.Companion.PANE import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest @@ -48,18 +63,22 @@ import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview import com.stripe.android.financialconnections.ui.TextResource import com.stripe.android.financialconnections.ui.components.AnnotatedText import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsModalBottomSheetLayout import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar -import com.stripe.android.financialconnections.ui.components.StringAnnotation import com.stripe.android.financialconnections.ui.components.elevation import com.stripe.android.financialconnections.ui.sdui.BulletUI import com.stripe.android.financialconnections.ui.sdui.fromHtml -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography +import com.stripe.android.financialconnections.ui.theme.Layout import com.stripe.android.financialconnections.ui.theme.StripeThemeForConnections import com.stripe.android.model.ConsumerSessionLookup +import com.stripe.android.uicore.elements.DropDown import com.stripe.android.uicore.elements.PhoneNumberCollectionSection import com.stripe.android.uicore.elements.TextFieldController import com.stripe.android.uicore.elements.TextFieldSection +import kotlinx.coroutines.launch @Composable internal fun NetworkingLinkSignupScreen() { @@ -68,11 +87,15 @@ internal fun NetworkingLinkSignupScreen() { val state = viewModel.collectAsState() BackHandler(enabled = true) {} val uriHandler = LocalUriHandler.current + val bottomSheetState: ModalBottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden + ) state.value.viewEffect?.let { viewEffect -> LaunchedEffect(viewEffect) { when (viewEffect) { is OpenUrl -> uriHandler.openUri(viewEffect.url) + is ViewEffect.OpenBottomSheet -> bottomSheetState.show() } viewModel.onViewEffectLaunched() } @@ -80,6 +103,7 @@ internal fun NetworkingLinkSignupScreen() { NetworkingLinkSignupContent( state = state.value, + bottomSheetState = bottomSheetState, onCloseClick = { parentViewModel.onCloseWithConfirmationClick(PANE) }, onCloseFromErrorClick = parentViewModel::onCloseFromErrorClick, onClickableTextClick = viewModel::onClickableTextClick, @@ -90,20 +114,57 @@ internal fun NetworkingLinkSignupScreen() { @Composable private fun NetworkingLinkSignupContent( + bottomSheetState: ModalBottomSheetState, state: NetworkingLinkSignupState, onCloseClick: () -> Unit, onCloseFromErrorClick: (Throwable) -> Unit, onClickableTextClick: (String) -> Unit, onSaveToLink: () -> Unit, onSkipClick: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + + FinancialConnectionsModalBottomSheetLayout( + sheetState = bottomSheetState, + sheetContent = { + when (val legalDetails = state.payload()?.content?.legalDetailsNotice) { + null -> {} + else -> LegalDetailsBottomSheetContent( + legalDetails = legalDetails, + onConfirmModalClick = { coroutineScope.launch { bottomSheetState.hide() } }, + onClickableTextClick = onClickableTextClick + ) + } + }, + content = { + NetworkingLinkSignupMainContent( + onCloseClick = onCloseClick, + state = state, + onSaveToLink = onSaveToLink, + onClickableTextClick = onClickableTextClick, + onSkipClick = onSkipClick, + onCloseFromErrorClick = onCloseFromErrorClick + ) + } + ) +} + +@Composable +private fun NetworkingLinkSignupMainContent( + onCloseClick: () -> Unit, + state: NetworkingLinkSignupState, + onSaveToLink: () -> Unit, + onClickableTextClick: (String) -> Unit, + onSkipClick: () -> Unit, + onCloseFromErrorClick: (Throwable) -> Unit ) { val scrollState = rememberScrollState() FinancialConnectionsScaffold( topBar = { FinancialConnectionsTopAppBar( - showBack = false, + elevation = scrollState.elevation, + allowBackNavigation = false, onCloseClick = onCloseClick, - elevation = scrollState.elevation ) } ) { @@ -111,7 +172,7 @@ private fun NetworkingLinkSignupContent( Uninitialized, is Loading -> FullScreenGenericLoading() is Success -> NetworkingLinkSignupLoaded( scrollState = scrollState, - validForm = state.valid(), + validForm = state.valid, payload = payload(), lookupAccountSync = state.lookupAccount, saveAccountToLinkSync = state.saveAccountToLink, @@ -141,63 +202,87 @@ private fun NetworkingLinkSignupLoaded( onSaveToLink: () -> Unit, onSkipClick: () -> Unit ) { - Column( - Modifier.fillMaxSize() - ) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(scrollState) - .padding( - top = 0.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) - ) { - Spacer(modifier = Modifier.size(16.dp)) + val phoneNumberFocusRequester = remember { FocusRequester() } + + LaunchedEffect(showFullForm) { + if (showFullForm) { + scrollState.animateScrollToBottom() + phoneNumberFocusRequester.requestFocus() + } + } + + Layout( + scrollState = scrollState, + body = { Title(payload.content.title) - Spacer(modifier = Modifier.size(8.dp)) - payload.content.body.bullets.forEach { - BulletItem( - bullet = BulletUI.from(it), + Spacer(modifier = Modifier.size(24.dp)) + + for (bullet in payload.content.body.bullets) { + ListItem( + bullet = BulletUI.from(bullet), onClickableTextClick = onClickableTextClick ) - Spacer(modifier = Modifier.size(8.dp)) + Spacer(modifier = Modifier.size(16.dp)) } + EmailSection( showFullForm = showFullForm, loading = lookupAccountSync is Loading, emailController = payload.emailController, enabled = true, ) - if (showFullForm) { + + AnimatedVisibility(showFullForm) { PhoneNumberSection( payload = payload, - onClickableTextClick = onClickableTextClick + focusRequester = phoneNumberFocusRequester, ) } - Spacer(modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.height(16.dp)) - if (showFullForm) { - SaveToLinkCta( - text = payload.content.cta, - aboveCta = payload.content.aboveCta, - onClickableTextClick = onClickableTextClick, - saveAccountToLinkSync = saveAccountToLinkSync, - validForm = validForm, - onSaveToLink = onSaveToLink - ) - } - Spacer(modifier = Modifier.size(12.dp)) - SkipCta(payload.content.skipCta, onSkipClick) + }, + footer = { + NetworkingLinkSignupFooter( + payload = payload, + onClickableTextClick = onClickableTextClick, + saveAccountToLinkSync = saveAccountToLinkSync, + validForm = validForm, + onSaveToLink = onSaveToLink, + onSkipClick = onSkipClick + ) } - } + ) } @OptIn(ExperimentalComposeUiApi::class) @Composable -private fun SkipCta(text: String, onSkipClick: () -> Unit) { +private fun NetworkingLinkSignupFooter( + payload: Payload, + onClickableTextClick: (String) -> Unit, + saveAccountToLinkSync: Async , + validForm: Boolean, + onSaveToLink: () -> Unit, + onSkipClick: () -> Unit +) = Column { + AnnotatedText( + modifier = Modifier.fillMaxWidth(), + text = TextResource.Text(fromHtml(payload.content.aboveCta)), + onClickableTextClick = onClickableTextClick, + defaultStyle = typography.labelSmall.copy( + textAlign = TextAlign.Center, + color = colors.textDefault + ) + ) + Spacer(modifier = Modifier.size(16.dp)) + FinancialConnectionsButton( + loading = saveAccountToLinkSync is Loading, + enabled = validForm, + type = FinancialConnectionsButton.Type.Primary, + onClick = onSaveToLink, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = payload.content.cta) + } + Spacer(modifier = Modifier.size(8.dp)) FinancialConnectionsButton( type = FinancialConnectionsButton.Type.Secondary, onClick = onSkipClick, @@ -206,80 +291,39 @@ private fun SkipCta(text: String, onSkipClick: () -> Unit) { .semantics { testTagsAsResourceId = true } .testTag("skip_cta") ) { - Text(text = text) - } -} - -@Composable -private fun SaveToLinkCta( - aboveCta: String, - text: String, - onClickableTextClick: (String) -> Unit, - saveAccountToLinkSync: Async , - validForm: Boolean, - onSaveToLink: () -> Unit -) { - Column { - AnnotatedText( - modifier = Modifier.fillMaxWidth(), - text = TextResource.Text(fromHtml(aboveCta)), - onClickableTextClick = onClickableTextClick, - defaultStyle = FinancialConnectionsTheme.typography.caption.copy( - textAlign = TextAlign.Center, - color = FinancialConnectionsTheme.colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.captionEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textBrand), - StringAnnotation.BOLD to FinancialConnectionsTheme.typography.captionEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textSecondary), - ) - ) - Spacer(modifier = Modifier.size(8.dp)) - FinancialConnectionsButton( - loading = saveAccountToLinkSync is Loading, - enabled = validForm, - type = FinancialConnectionsButton.Type.Primary, - onClick = onSaveToLink, - modifier = Modifier - .fillMaxWidth() - ) { - Text(text = text) - } + Text(text = payload.content.skipCta) } } @Composable private fun PhoneNumberSection( payload: Payload, - onClickableTextClick: (String) -> Unit + focusRequester: FocusRequester, ) { + var focused by remember { mutableStateOf(false) } Column { StripeThemeForConnections { PhoneNumberCollectionSection( - requestFocusWhenShown = payload.phoneController.initialPhoneNumber.isEmpty(), + modifier = Modifier.onFocusChanged { focused = it.isFocused }, + countryDropdown = { + DropDown( + controller = payload.phoneController.countryDropdownController, + enabled = true, + showChevron = false, + modifier = Modifier + .padding(horizontal = 6.dp) + .clip(RoundedCornerShape(8.dp)) + .background(colors.background) + .padding(vertical = 12.dp, horizontal = 8.dp) + ) + }, + isSelected = focused, phoneNumberController = payload.phoneController, imeAction = ImeAction.Default, + focusRequester = focusRequester, enabled = true, ) } - AnnotatedText( - text = TextResource.StringId(R.string.stripe_networking_signup_phone_number_disclaimer), - onClickableTextClick = onClickableTextClick, - defaultStyle = FinancialConnectionsTheme.typography.caption.copy( - color = FinancialConnectionsTheme.colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.captionEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textBrand), - StringAnnotation.BOLD to FinancialConnectionsTheme.typography.captionEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textSecondary), - ) - ) } } @@ -287,12 +331,7 @@ private fun PhoneNumberSection( private fun Title(title: String) { AnnotatedText( text = TextResource.Text(fromHtml(title)), - defaultStyle = FinancialConnectionsTheme.typography.subtitle, - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.subtitle - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textBrand), - ), + defaultStyle = typography.headingXLarge, onClickableTextClick = {}, ) } @@ -304,6 +343,7 @@ internal fun EmailSection( showFullForm: Boolean, loading: Boolean ) { + var focused by remember { mutableStateOf(false) } StripeThemeForConnections { Box( modifier = Modifier @@ -312,12 +352,10 @@ internal fun EmailSection( contentAlignment = Alignment.CenterEnd ) { TextFieldSection( + modifier = Modifier.onFocusChanged { focused = it.isFocused }, + isSelected = focused, textFieldController = emailController, - imeAction = if (showFullForm) { - ImeAction.Next - } else { - ImeAction.Done - }, + imeAction = if (showFullForm) ImeAction.Next else ImeAction.Done, enabled = enabled ) if (loading) { @@ -330,7 +368,7 @@ internal fun EmailSection( end = 16.dp, bottom = 8.dp ), - color = FinancialConnectionsTheme.colors.iconBrand, + color = colors.iconBrand, strokeWidth = 2.dp ) } @@ -338,6 +376,12 @@ internal fun EmailSection( } } +private suspend fun ScrollState.animateScrollToBottom( + animationSpec: AnimationSpec = tween(), +) { + animateScrollBy(Float.MAX_VALUE, animationSpec) +} + @Composable @Preview(group = "NetworkingLinkSignup Pane") internal fun NetworkingLinkSignupScreenPreview( @@ -347,6 +391,9 @@ internal fun NetworkingLinkSignupScreenPreview( FinancialConnectionsPreview { NetworkingLinkSignupContent( state = state, + bottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden + ), onCloseClick = {}, onSaveToLink = {}, onClickableTextClick = {}, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModel.kt index 8fefbac9436..6fd7bb09f4b 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModel.kt @@ -8,6 +8,7 @@ import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.R import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.Click import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.NetworkingNewConsumer import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.NetworkingReturningConsumer @@ -20,6 +21,7 @@ import com.stripe.android.financialconnections.domain.LookupAccount import com.stripe.android.financialconnections.domain.SaveAccountToLink import com.stripe.android.financialconnections.domain.SynchronizeFinancialConnectionsSession import com.stripe.android.financialconnections.features.common.getBusinessName +import com.stripe.android.financialconnections.features.networkinglinksignup.NetworkingLinkSignupState.ViewEffect.OpenBottomSheet import com.stripe.android.financialconnections.features.networkinglinksignup.NetworkingLinkSignupState.ViewEffect.OpenUrl import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane @@ -43,7 +45,6 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.security.InvalidParameterException import java.util.Date import javax.inject.Inject @@ -72,8 +73,11 @@ internal class NetworkingLinkSignupViewModel @Inject constructor( NetworkingLinkSignupState.Payload( content = content, merchantName = manifest.getBusinessName(), - emailController = EmailConfig - .createController(manifest.accountholderCustomerEmailAddress), + emailController = SimpleTextFieldController( + textFieldConfig = EmailConfig(label = R.string.stripe_networking_signup_email_label), + initialValue = manifest.accountholderCustomerEmailAddress, + showOptionalLabel = false + ), phoneController = PhoneNumberController .createPhoneNumberController(manifest.accountholderPhoneNumber ?: ""), ) @@ -92,7 +96,7 @@ internal class NetworkingLinkSignupViewModel @Inject constructor( onSuccess = { consumerSession -> if (consumerSession.exists) { eventTracker.track(NetworkingReturningConsumer(PANE)) - navigationManager.tryNavigateTo(NetworkingSaveToLinkVerification(referrer = PANE)) + navigateToLinkVerification() } else { eventTracker.track(NetworkingNewConsumer(PANE)) } @@ -113,6 +117,7 @@ internal class NetworkingLinkSignupViewModel @Inject constructor( NetworkingLinkSignupState::saveAccountToLink, onSuccess = { saveToLinkWithStripeSucceeded.set(true) + navigationManager.tryNavigateTo(Success(referrer = PANE)) }, onFail = { error -> saveToLinkWithStripeSucceeded.set(false) @@ -188,23 +193,38 @@ internal class NetworkingLinkSignupViewModel @Inject constructor( } fun onSaveAccount() { + withState { state -> + eventTracker.track(Click(eventName = "click.save_to_link", pane = PANE)) + + val hasExistingAccount = state.lookupAccount()?.exists == true + if (hasExistingAccount) { + navigateToLinkVerification() + } else { + saveNewAccount() + } + } + } + + private fun saveNewAccount() { suspend { eventTracker.track(Click(eventName = "click.save_to_link", pane = PANE)) val state = awaitState() val selectedAccounts = getCachedAccounts() val phoneController = state.payload()!!.phoneController - require(state.valid()) { "Form invalid! ${state.validEmail} ${state.validPhone}" } + require(state.valid) { "Form invalid! ${state.validEmail} ${state.validPhone}" } saveAccountToLink.new( country = phoneController.getCountryCode(), email = state.validEmail!!, phoneNumber = phoneController.getE164PhoneNumber(state.validPhone!!), selectedAccounts = selectedAccounts.map { it.id }, - ).also { - navigationManager.tryNavigateTo(Success(referrer = PANE)) - } + ) }.execute { copy(saveAccountToLink = it) } } + private fun navigateToLinkVerification() { + navigationManager.tryNavigateTo(NetworkingSaveToLinkVerification(referrer = PANE)) + } + fun onClickableTextClick(uri: String) = viewModelScope.launch { // if clicked uri contains an eventName query param, track click event. uriUtils.getQueryParameter(uri, "eventName")?.let { eventName -> @@ -214,12 +234,17 @@ internal class NetworkingLinkSignupViewModel @Inject constructor( if (URLUtil.isNetworkUrl(uri)) { setState { copy(viewEffect = OpenUrl(uri, date.time)) } } else { - eventTracker.logError( - extraMessage = "Error clicking text", - logger = logger, - pane = PANE, - error = InvalidParameterException("Unrecognized clickable text: $uri") - ) + val managedUri = NetworkingLinkSignupClickableText.values() + .firstOrNull { uriUtils.compareSchemeAuthorityAndPath(it.value, uri) } + when (managedUri) { + NetworkingLinkSignupClickableText.LEGAL_DETAILS -> { + setState { + copy(viewEffect = OpenBottomSheet(date.time)) + } + } + + null -> logger.error("Unrecognized clickable text: $uri") + } } } @@ -269,9 +294,11 @@ internal data class NetworkingLinkSignupState( val showFullForm: Boolean get() = lookupAccount()?.let { !it.exists } ?: false - fun valid(): Boolean { - return validEmail != null && validPhone != null - } + val valid: Boolean + get() { + val hasExistingAccount = lookupAccount()?.exists == true + return validEmail != null && (hasExistingAccount || validPhone != null) + } data class Payload( val merchantName: String?, @@ -285,5 +312,13 @@ internal data class NetworkingLinkSignupState( val url: String, val id: Long ) : ViewEffect() + + data class OpenBottomSheet( + val id: Long + ) : ViewEffect() } } + +private enum class NetworkingLinkSignupClickableText(val value: String) { + LEGAL_DETAILS("stripe://legal-details-notice"), +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationPreviewParameterProvider.kt new file mode 100644 index 00000000000..17b189bd825 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationPreviewParameterProvider.kt @@ -0,0 +1,65 @@ +package com.stripe.android.financialconnections.features.networkinglinkverification + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.stripe.android.financialconnections.domain.ConfirmVerification +import com.stripe.android.uicore.elements.IdentifierSpec +import com.stripe.android.uicore.elements.OTPController +import com.stripe.android.uicore.elements.OTPElement + +internal class NetworkingLinkVerificationPreviewParameterProvider : + PreviewParameterProvider { + override val values = sequenceOf( + loading(), + canonical(), + submitting(), + otpError(), + unknownError() + ) + + private fun canonical() = NetworkingLinkVerificationState( + payload = payload(), + confirmVerification = Uninitialized + ) + + private fun submitting() = NetworkingLinkVerificationState( + payload = payload(), + confirmVerification = Loading() + ) + + private fun otpError() = NetworkingLinkVerificationState( + payload = payload(), + confirmVerification = Fail( + ConfirmVerification.OTPError( + "12345678", + ConfirmVerification.OTPError.Type.EMAIL_CODE_EXPIRED + ) + ) + ) + + private fun unknownError() = NetworkingLinkVerificationState( + payload = payload(), + confirmVerification = Fail( + Exception("Random error") + ) + ) + + private fun payload() = Success( + NetworkingLinkVerificationState.Payload( + email = "theLargestEmailYoulleverseeThatCouldBreakALayout@email.com", + phoneNumber = "12345678", + otpElement = OTPElement( + IdentifierSpec.Generic("otp"), + OTPController() + ), + consumerSessionClientSecret = "12345678" + ) + ) + + private fun loading() = NetworkingLinkVerificationState( + payload = Loading(), + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationScreen.kt index ee2134d53ad..1171fca952f 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationScreen.kt @@ -3,23 +3,23 @@ package com.stripe.android.financialconnections.features.networkinglinkverification import androidx.annotation.RestrictTo -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalTextInputService import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.airbnb.mvrx.Async import com.airbnb.mvrx.Fail @@ -29,24 +29,19 @@ import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.domain.ConfirmVerification -import com.stripe.android.financialconnections.domain.ConfirmVerification.OTPError.Type +import com.stripe.android.financialconnections.domain.ConfirmVerification.OTPError import com.stripe.android.financialconnections.features.common.FullScreenGenericLoading +import com.stripe.android.financialconnections.features.common.LoadingSpinner import com.stripe.android.financialconnections.features.common.UnclassifiedErrorContent import com.stripe.android.financialconnections.features.common.VerificationSection import com.stripe.android.financialconnections.features.networkinglinkverification.NetworkingLinkVerificationState.Payload import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.presentation.parentViewModel import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview -import com.stripe.android.financialconnections.ui.TextResource -import com.stripe.android.financialconnections.ui.components.AnnotatedText import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar -import com.stripe.android.financialconnections.ui.components.StringAnnotation import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme -import com.stripe.android.uicore.elements.IdentifierSpec -import com.stripe.android.uicore.elements.OTPController -import com.stripe.android.uicore.elements.OTPElement +import com.stripe.android.financialconnections.ui.theme.LazyLayout @Composable internal fun NetworkingLinkVerificationScreen() { @@ -55,7 +50,7 @@ internal fun NetworkingLinkVerificationScreen() { val state = viewModel.collectAsState() NetworkingLinkVerificationContent( state = state.value, - onCloseClick = { parentViewModel.onCloseWithConfirmationClick(Pane.NETWORKING_LINK_SIGNUP_PANE) }, + onCloseClick = { parentViewModel.onCloseWithConfirmationClick(Pane.NETWORKING_LINK_VERIFICATION) }, onCloseFromErrorClick = parentViewModel::onCloseFromErrorClick, ) } @@ -66,7 +61,6 @@ private fun NetworkingLinkVerificationContent( onCloseClick: () -> Unit, onCloseFromErrorClick: (Throwable) -> Unit, ) { - val scrollState = rememberScrollState() FinancialConnectionsScaffold( topBar = { FinancialConnectionsTopAppBar( @@ -77,9 +71,9 @@ private fun NetworkingLinkVerificationContent( when (val payload = state.payload) { Uninitialized, is Loading -> FullScreenGenericLoading() is Success -> NetworkingLinkVerificationLoaded( - scrollState = scrollState, payload = payload(), - confirmVerificationAsync = state.confirmVerification + confirmVerificationAsync = state.confirmVerification, + onCloseFromErrorClick = onCloseFromErrorClick ) is Fail -> UnclassifiedErrorContent( @@ -93,8 +87,8 @@ private fun NetworkingLinkVerificationContent( @Composable private fun NetworkingLinkVerificationLoaded( confirmVerificationAsync: Async , - scrollState: ScrollState, payload: Payload, + onCloseFromErrorClick: (Throwable) -> Unit, ) { val focusManager = LocalFocusManager.current val focusRequester: FocusRequester = remember { FocusRequester() } @@ -103,135 +97,68 @@ private fun NetworkingLinkVerificationLoaded( LaunchedEffect(confirmVerificationAsync) { if (confirmVerificationAsync is Loading) { focusManager.clearFocus(true) - @Suppress() + @Suppress("DEPRECATION") textInputService?.hideSoftwareKeyboard() } } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding( - top = 0.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) - ) { - Spacer(modifier = Modifier.size(16.dp)) - Title() - Spacer(modifier = Modifier.size(8.dp)) - Description( - phoneNumber = payload.phoneNumber + if (confirmVerificationAsync is Fail && confirmVerificationAsync.error !is OTPError) { + UnclassifiedErrorContent( + error = confirmVerificationAsync.error, + onCloseFromErrorClick = onCloseFromErrorClick ) - Spacer(modifier = Modifier.size(24.dp)) - VerificationSection( - focusRequester = focusRequester, - otpElement = payload.otpElement, - enabled = confirmVerificationAsync !is Loading, - confirmVerificationError = (confirmVerificationAsync as? Fail)?.error + } else { + LazyLayout( + verticalArrangement = Arrangement.spacedBy(24.dp), + body = { + item { Header(payload) } + item { + VerificationSection( + focusRequester = focusRequester, + otpElement = payload.otpElement, + enabled = confirmVerificationAsync !is Loading, + confirmVerificationError = (confirmVerificationAsync as? Fail)?.error + ) + } + if (confirmVerificationAsync is Loading) { + item { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + LoadingSpinner(Modifier.size(24.dp)) + } + } + } + } ) - Spacer(modifier = Modifier.size(24.dp)) - EmailSubtext(payload.email) } } @Composable -private fun EmailSubtext(email: String) { - AnnotatedText( - text = TextResource.Text( - stringResource( - R.string.stripe_networking_verification_email, - email - ) - ), - defaultStyle = FinancialConnectionsTheme.typography.caption.copy( - color = FinancialConnectionsTheme.colors.textDisabled - ), - annotationStyles = emptyMap(), - onClickableTextClick = {}, - ) -} - -@Composable -private fun Description(phoneNumber: String) { - AnnotatedText( - text = TextResource.StringId( - R.string.stripe_networking_verification_desc, - listOf(phoneNumber) - ), - defaultStyle = FinancialConnectionsTheme.typography.body.copy( - color = FinancialConnectionsTheme.colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.BOLD to FinancialConnectionsTheme.typography.bodyEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textSecondary), - ), - onClickableTextClick = {}, - ) -} - -@Composable -private fun Title() { - AnnotatedText( - text = TextResource.Text( - stringResource(R.string.stripe_networking_verification_title) - ), - defaultStyle = FinancialConnectionsTheme.typography.subtitle, - annotationStyles = emptyMap(), - onClickableTextClick = {}, - ) -} - -@Composable -@Preview(group = "NetworkingLinkVerification Pane", name = "Entering OTP") -internal fun NetworkingLinkVerificationScreenPreview() { - FinancialConnectionsPreview { - NetworkingLinkVerificationContent( - state = NetworkingLinkVerificationState( - payload = Success( - Payload( - email = "email@gmail.com", - phoneNumber = "12345678", - otpElement = OTPElement( - IdentifierSpec.Generic("otp"), - OTPController() - ), - consumerSessionClientSecret = "12345678" - ) - ) - ), - onCloseClick = {}, - onCloseFromErrorClick = {} +private fun Header(payload: Payload) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.stripe_networking_verification_title), + style = FinancialConnectionsTheme.typography.headingXLarge, + ) + Text( + text = stringResource(R.string.stripe_networking_verification_desc, payload.phoneNumber), + style = FinancialConnectionsTheme.typography.bodyMedium, ) } } @Composable -@Preview(group = "NetworkingLinkVerification Pane", name = "Error") -internal fun NetworkingLinkVerificationScreenWithErrorPreview() { +@Preview +internal fun NetworkingLinkVerificationPreview( + @PreviewParameter(NetworkingLinkVerificationPreviewParameterProvider::class) + state: NetworkingLinkVerificationState +) { FinancialConnectionsPreview { NetworkingLinkVerificationContent( - state = NetworkingLinkVerificationState( - confirmVerification = Fail( - ConfirmVerification.OTPError( - message = "consumer_verification_max_attempts_exceeded", - type = Type.SMS_CODE_EXPIRED - ) - ), - payload = Success( - Payload( - email = "email@gmail.com", - phoneNumber = "12345678", - otpElement = OTPElement( - IdentifierSpec.Generic("otp"), - OTPController() - ), - consumerSessionClientSecret = "12345678" - ) - ) - ), + state = state, onCloseClick = {}, onCloseFromErrorClick = {} ) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModel.kt index ecf181ab068..29009d7eae5 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModel.kt @@ -133,7 +133,7 @@ internal class NetworkingLinkVerificationViewModel @Inject constructor( ) }.execute { copy(confirmVerification = it) } - private suspend fun onNetworkedAccountsFailed( + private fun onNetworkedAccountsFailed( error: Throwable, updatedManifest: FinancialConnectionsSessionManifest ) { diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationPreviewParameterProvider.kt new file mode 100644 index 00000000000..129a76a2aed --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationPreviewParameterProvider.kt @@ -0,0 +1,65 @@ +package com.stripe.android.financialconnections.features.networkingsavetolinkverification + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.stripe.android.financialconnections.domain.ConfirmVerification +import com.stripe.android.uicore.elements.IdentifierSpec +import com.stripe.android.uicore.elements.OTPController +import com.stripe.android.uicore.elements.OTPElement + +internal class NetworkingSaveToLinkVerificationPreviewParameterProvider : + PreviewParameterProvider { + override val values = sequenceOf( + loading(), + canonical(), + submitting(), + otpError(), + randomError() + ) + + private fun canonical() = NetworkingSaveToLinkVerificationState( + payload = payload(), + confirmVerification = Uninitialized + ) + + private fun submitting() = NetworkingSaveToLinkVerificationState( + payload = payload(), + confirmVerification = Loading() + ) + + private fun otpError() = NetworkingSaveToLinkVerificationState( + payload = payload(), + confirmVerification = Fail( + ConfirmVerification.OTPError( + "12345678", + ConfirmVerification.OTPError.Type.EMAIL_CODE_EXPIRED + ) + ) + ) + + private fun randomError() = NetworkingSaveToLinkVerificationState( + payload = payload(), + confirmVerification = Fail( + Exception("Random error") + ) + ) + + private fun payload() = Success( + NetworkingSaveToLinkVerificationState.Payload( + email = "theLargestEmailYoulleverseeThatCouldBreakALayout@email.com", + phoneNumber = "12345678", + otpElement = OTPElement( + IdentifierSpec.Generic("otp"), + OTPController() + ), + consumerSessionClientSecret = "12345678" + ) + ) + + private fun loading() = NetworkingSaveToLinkVerificationState( + payload = Loading(), + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationScreen.kt index 6c22af653cc..1fd42a11e63 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationScreen.kt @@ -3,25 +3,25 @@ package com.stripe.android.financialconnections.features.networkingsavetolinkverification import androidx.annotation.RestrictTo -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalTextInputService import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.airbnb.mvrx.Async import com.airbnb.mvrx.Fail @@ -31,23 +31,21 @@ import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.domain.ConfirmVerification import com.stripe.android.financialconnections.features.common.FullScreenGenericLoading +import com.stripe.android.financialconnections.features.common.LoadingSpinner import com.stripe.android.financialconnections.features.common.UnclassifiedErrorContent import com.stripe.android.financialconnections.features.common.VerificationSection import com.stripe.android.financialconnections.features.networkingsavetolinkverification.NetworkingSaveToLinkVerificationState.Payload import com.stripe.android.financialconnections.features.networkingsavetolinkverification.NetworkingSaveToLinkVerificationViewModel.Companion.PANE import com.stripe.android.financialconnections.presentation.parentViewModel import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview -import com.stripe.android.financialconnections.ui.TextResource -import com.stripe.android.financialconnections.ui.components.AnnotatedText import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar -import com.stripe.android.financialconnections.ui.components.StringAnnotation +import com.stripe.android.financialconnections.ui.components.elevation import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme -import com.stripe.android.uicore.elements.IdentifierSpec -import com.stripe.android.uicore.elements.OTPController -import com.stripe.android.uicore.elements.OTPElement +import com.stripe.android.financialconnections.ui.theme.LazyLayout @Composable internal fun NetworkingSaveToLinkVerificationScreen() { @@ -69,21 +67,22 @@ private fun NetworkingSaveToLinkVerificationContent( onSkipClick: () -> Unit, onCloseFromErrorClick: (Throwable) -> Unit, ) { - val scrollState = rememberScrollState() + val lazyListState = rememberLazyListState() FinancialConnectionsScaffold( topBar = { FinancialConnectionsTopAppBar( - showBack = true, - onCloseClick = onCloseClick + onCloseClick = onCloseClick, + elevation = rememberLazyListState().elevation ) } ) { when (val payload = state.payload) { Uninitialized, is Loading -> FullScreenGenericLoading() is Success -> NetworkingSaveToLinkVerificationLoaded( - scrollState = scrollState, + lazyListState = lazyListState, payload = payload(), confirmVerificationAsync = state.confirmVerification, + onCloseFromErrorClick = onCloseFromErrorClick, onSkipClick = onSkipClick ) @@ -98,8 +97,9 @@ private fun NetworkingSaveToLinkVerificationContent( @Composable private fun NetworkingSaveToLinkVerificationLoaded( confirmVerificationAsync: Async , - scrollState: ScrollState, + lazyListState: LazyListState, payload: Payload, + onCloseFromErrorClick: (Throwable) -> Unit, onSkipClick: () -> Unit, ) { val focusManager = LocalFocusManager.current @@ -113,113 +113,81 @@ private fun NetworkingSaveToLinkVerificationLoaded( } } LaunchedEffect(Unit) { focusRequester.requestFocus() } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding( - top = 0.dp, - start = 24.dp, - end = 24.dp, - bottom = 24.dp - ) - ) { - Spacer(modifier = Modifier.size(16.dp)) - Title() - Spacer(modifier = Modifier.size(8.dp)) - Description(payload.phoneNumber) - Spacer(modifier = Modifier.size(24.dp)) - VerificationSection( - focusRequester = focusRequester, - otpElement = payload.otpElement, - enabled = confirmVerificationAsync !is Loading, - confirmVerificationError = (confirmVerificationAsync as? Fail)?.error + if (confirmVerificationAsync is Fail && confirmVerificationAsync.error !is ConfirmVerification.OTPError) { + UnclassifiedErrorContent( + error = confirmVerificationAsync.error, + onCloseFromErrorClick = onCloseFromErrorClick + ) + } else { + LazyLayout( + lazyListState = lazyListState, + verticalArrangement = Arrangement.spacedBy(24.dp), + body = { + item { Header(payload) } + item { + VerificationSection( + focusRequester = focusRequester, + otpElement = payload.otpElement, + enabled = confirmVerificationAsync !is Loading, + confirmVerificationError = (confirmVerificationAsync as? Fail)?.error + ) + } + if (confirmVerificationAsync is Loading) { + item { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + LoadingSpinner(Modifier.size(24.dp)) + } + } + } + }, + footer = { + FinancialConnectionsButton( + type = FinancialConnectionsButton.Type.Secondary, + onClick = onSkipClick, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = stringResource(R.string.stripe_networking_save_to_link_verification_cta_negative)) + } + } ) - Spacer(modifier = Modifier.size(24.dp)) - EmailSubtext(payload.email) - Spacer(modifier = Modifier.weight(1f)) - FinancialConnectionsButton( - type = FinancialConnectionsButton.Type.Secondary, - onClick = onSkipClick, - modifier = Modifier - .fillMaxWidth() - ) { - Text(text = stringResource(R.string.stripe_networking_save_to_link_verification_cta_negative)) - } } } @Composable -private fun EmailSubtext(email: String) { - AnnotatedText( - text = TextResource.Text( - stringResource( - R.string.stripe_networking_verification_email, - email - ) - ), - defaultStyle = FinancialConnectionsTheme.typography.caption.copy( - color = FinancialConnectionsTheme.colors.textDisabled - ), - annotationStyles = emptyMap(), - onClickableTextClick = {}, - ) -} - -@Composable -private fun Description(phoneNumber: String) { - AnnotatedText( - text = TextResource.Text( - stringResource( +private fun Header(payload: Payload) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.stripe_networking_save_to_link_verification_title), + style = FinancialConnectionsTheme.typography.headingXLarge, + ) + Text( + text = stringResource( R.string.stripe_networking_verification_desc, - phoneNumber - ) - ), - defaultStyle = FinancialConnectionsTheme.typography.body.copy( - color = FinancialConnectionsTheme.colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.BOLD to FinancialConnectionsTheme.typography.bodyEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textSecondary), - ), - onClickableTextClick = {}, - ) -} - -@Composable -private fun Title() { - AnnotatedText( - text = TextResource.Text( - stringResource(R.string.stripe_networking_save_to_link_verification_title) - ), - defaultStyle = FinancialConnectionsTheme.typography.subtitle, - annotationStyles = emptyMap(), - onClickableTextClick = {}, - ) + payload.phoneNumber + ), + style = FinancialConnectionsTheme.typography.bodyMedium, + ) + } } @Composable -@Preview(group = "NetworkingVerification", name = "Default") -internal fun NetworkingSaveToLinkVerificationScreenPreview() { +@Preview +internal fun SaveToLinkVerificationPreview( + @PreviewParameter(NetworkingSaveToLinkVerificationPreviewParameterProvider::class) + state: NetworkingSaveToLinkVerificationState +) { FinancialConnectionsPreview { NetworkingSaveToLinkVerificationContent( - state = NetworkingSaveToLinkVerificationState( - payload = Success( - Payload( - email = "12345678", - phoneNumber = "12345678", - otpElement = OTPElement( - IdentifierSpec.Generic("otp"), - OTPController() - ), - consumerSessionClientSecret = "12345678" - ) - ) - ), + state = state, onCloseClick = {}, - onCloseFromErrorClick = {}, - onSkipClick = {} + onSkipClick = {}, + onCloseFromErrorClick = {} ) } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt index f0a01565900..d5e052cf79e 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt @@ -29,6 +29,7 @@ import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativ import com.stripe.android.uicore.elements.IdentifierSpec import com.stripe.android.uicore.elements.OTPController import com.stripe.android.uicore.elements.OTPElement +import getRedactedPhoneNumber import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import javax.inject.Inject @@ -59,7 +60,7 @@ internal class NetworkingSaveToLinkVerificationViewModel @Inject constructor( eventTracker.track(PaneLoaded(PANE)) NetworkingSaveToLinkVerificationState.Payload( email = consumerSession.emailAddress, - phoneNumber = consumerSession.redactedPhoneNumber, + phoneNumber = consumerSession.getRedactedPhoneNumber(), consumerSessionClientSecret = consumerSession.clientSecret, otpElement = OTPElement( IdentifierSpec.Generic("otp"), diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthPreviewParameterProvider.kt index bf366648aa1..052016136bc 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthPreviewParameterProvider.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthPreviewParameterProvider.kt @@ -1,5 +1,3 @@ -@file:Suppress("LongMethod") - package com.stripe.android.financialconnections.features.partnerauth import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -22,6 +20,7 @@ internal class PartnerAuthPreviewParameterProvider : PreviewParameterProvider { override val values = sequenceOf( canonical(), + prepaneLoading(), browserLoading() ) @@ -31,6 +30,7 @@ internal class PartnerAuthPreviewParameterProvider : private fun canonical() = SharedPartnerAuthState( payload = Success( SharedPartnerAuthState.Payload( + isStripeDirect = false, institution = FinancialConnectionsInstitution( id = "id", name = "name", @@ -42,7 +42,6 @@ internal class PartnerAuthPreviewParameterProvider : mobileHandoffCapable = false ), authSession = session(), - isStripeDirect = false ) ), authenticationStatus = Uninitialized, @@ -51,9 +50,18 @@ internal class PartnerAuthPreviewParameterProvider : pane = Pane.PARTNER_AUTH ) + private fun prepaneLoading() = SharedPartnerAuthState( + payload = Loading(), + authenticationStatus = Uninitialized, + viewEffect = null, + activeAuthSession = null, + pane = Pane.PARTNER_AUTH + ) + private fun browserLoading() = SharedPartnerAuthState( payload = Success( SharedPartnerAuthState.Payload( + isStripeDirect = false, institution = FinancialConnectionsInstitution( id = "id", name = "name", @@ -65,7 +73,6 @@ internal class PartnerAuthPreviewParameterProvider : mobileHandoffCapable = false ), authSession = session(), - isStripeDirect = false ) ), // While browser is showing, this Async is loading. @@ -94,23 +101,14 @@ internal class PartnerAuthPreviewParameterProvider : "https://b.stripecdn.com/connections-statics-srv/assets/PrepaneAsset--account_numbers-capitalone-2x.gif" return OauthPrepane( title = "Sign in with Sample bank", + subtitle = "Next, you'll be prompted to log in and connect your accounts.", body = Body( listOf( - Entry.Text( - "Some very large text will most likely go here!" + - "Some very large text will most likely go here!" - ), Entry.Image( Image(sampleImage) ), Entry.Text( - "Some very large text will most likely go here!" - ), - Entry.Text( - "Some very large text will most likely go here!" - ), - Entry.Text( - "Some very large text will most likely go here!" + "Dynamic content placeholder that will show below image." ) ) ), @@ -118,7 +116,7 @@ internal class PartnerAuthPreviewParameterProvider : icon = null, text = "Continue!" ), - institutionIcon = null, + institutionIcon = Image("www.image.url"), partnerNotice = PartnerNotice( partnerIcon = Image(sampleImage), text = "Stripe works with partners like MX to reliably" + diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt index 668bfc2d3d8..a5b11b0a818 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt @@ -1,5 +1,3 @@ -@file:Suppress("LongMethod") - package com.stripe.android.financialconnections.features.partnerauth import androidx.compose.runtime.Composable @@ -9,15 +7,15 @@ import com.airbnb.mvrx.compose.mavericksViewModel import com.stripe.android.financialconnections.features.common.SharedPartnerAuth @Composable -internal fun PartnerAuthScreen() { +internal fun PartnerAuthScreen(inModal: Boolean) { val viewModel: PartnerAuthViewModel = mavericksViewModel() val state: State = viewModel.collectAsState() SharedPartnerAuth( + inModal = inModal, state = state.value, onContinueClick = viewModel::onLaunchAuthClick, - onSelectAnotherBank = viewModel::onSelectAnotherBank, - onEnterDetailsManually = viewModel::onEnterDetailsManuallyClick, + onCancelClick = viewModel::onCancelClick, onClickableTextClick = viewModel::onClickableTextClick, onWebAuthFlowFinished = viewModel::onWebAuthFlowFinished, onViewEffectLaunched = viewModel::onViewEffectLaunched diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt index e3f7c3bae02..2f314ec4b59 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt @@ -27,12 +27,16 @@ import com.stripe.android.financialconnections.di.APPLICATION_ID import com.stripe.android.financialconnections.domain.CancelAuthorizationSession import com.stripe.android.financialconnections.domain.CompleteAuthorizationSession import com.stripe.android.financialconnections.domain.GetOrFetchSync +import com.stripe.android.financialconnections.domain.HandleError import com.stripe.android.financialconnections.domain.PollAuthorizationSessionOAuthResults import com.stripe.android.financialconnections.domain.PostAuthSessionEvent import com.stripe.android.financialconnections.domain.PostAuthorizationSession import com.stripe.android.financialconnections.domain.RetrieveAuthorizationSession +import com.stripe.android.financialconnections.exception.FinancialConnectionsError +import com.stripe.android.financialconnections.exception.PartnerAuthError import com.stripe.android.financialconnections.exception.WebAuthFlowFailedException import com.stripe.android.financialconnections.features.common.enableRetrieveAuthSession +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus.Action import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.Payload import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect.OpenPartnerAuth @@ -41,9 +45,8 @@ import com.stripe.android.financialconnections.model.FinancialConnectionsSession import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.SynchronizeSessionResponse import com.stripe.android.financialconnections.navigation.Destination.AccountPicker -import com.stripe.android.financialconnections.navigation.Destination.ManualEntry -import com.stripe.android.financialconnections.navigation.Destination.Reset import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.navigation.PopUpToBehavior import com.stripe.android.financialconnections.navigation.destination import com.stripe.android.financialconnections.presentation.WebAuthFlowState import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity @@ -52,8 +55,8 @@ import kotlinx.coroutines.launch import java.util.Date import javax.inject.Inject import javax.inject.Named +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus as Status -@Suppress("LongParameterList") internal class PartnerAuthViewModel @Inject constructor( private val completeAuthorizationSession: CompleteAuthorizationSession, private val createAuthorizationSession: PostAuthorizationSession, @@ -65,6 +68,7 @@ internal class PartnerAuthViewModel @Inject constructor( private val postAuthSessionEvent: PostAuthSessionEvent, private val getOrFetchSync: GetOrFetchSync, private val browserManager: BrowserManager, + private val handleError: HandleError, private val navigationManager: NavigationManager, private val pollAuthorizationSessionOAuthResults: PollAuthorizationSessionOAuthResults, private val logger: Logger, @@ -72,64 +76,58 @@ internal class PartnerAuthViewModel @Inject constructor( ) : MavericksViewModel (initialState) { init { - logErrors() - withState { - if (it.activeAuthSession == null) { - launchBrowserIfNonOauth() - createAuthSession() - } else { - logger.debug("Restoring auth session ${it.activeAuthSession}") - restoreAuthSession() - } - } + handleErrors() + launchBrowserIfNonOauth() + restoreOrCreateAuthSession() } - private fun restoreAuthSession() { - suspend { - // if coming from a process kill, there should be a session - // re-fetch the manifest and use its active auth session instead of creating a new one - val sync: SynchronizeSessionResponse = getOrFetchSync() - val manifest: FinancialConnectionsSessionManifest = sync.manifest - val authSession = manifest.activeAuthSession ?: createAuthorizationSession( - institution = requireNotNull(manifest.activeInstitution), - sync = sync - ) - Payload( - authSession = authSession, - institution = requireNotNull(manifest.activeInstitution), - isStripeDirect = manifest.isStripeDirect ?: false - ) - }.execute { copy(payload = it) } - } + private fun restoreOrCreateAuthSession() = suspend { + // A session should have been created in the previous pane and set as the active + // auth session in the manifest. + // if coming from a process kill, we'll fetch the current manifest from network, + // that should contain the active auth session. + val sync: SynchronizeSessionResponse = getOrFetchSync() + val manifest: FinancialConnectionsSessionManifest = sync.manifest + val authSession = manifest.activeAuthSession ?: createAuthorizationSession( + institution = requireNotNull(manifest.activeInstitution), + sync = sync + ) + Payload( + isStripeDirect = manifest.isStripeDirect ?: false, + institution = requireNotNull(manifest.activeInstitution), + authSession = authSession, + ) + }.execute { copy(payload = it) } - private fun createAuthSession() { - suspend { - val launchedEvent = Launched(Date()) - val sync: SynchronizeSessionResponse = getOrFetchSync() - val manifest: FinancialConnectionsSessionManifest = sync.manifest - val authSession = createAuthorizationSession( - institution = requireNotNull(manifest.activeInstitution), - sync = sync - ) - logger.debug("Created auth session ${authSession.id}") - Payload( - authSession = authSession, - institution = requireNotNull(manifest.activeInstitution), - isStripeDirect = manifest.isStripeDirect ?: false - ).also { - // just send loaded event on OAuth flows (prepane). Non-OAuth handled by shim. - val loadedEvent: Loaded? = Loaded(Date()).takeIf { authSession.isOAuth } - postAuthSessionEvent( - authSession.id, - listOfNotNull(launchedEvent, loadedEvent) - ) - } - }.execute { - copy( - payload = it, - activeAuthSession = it()?.authSession?.id + private fun recreateAuthSession() = suspend { + val launchedEvent = Launched(Date()) + val sync: SynchronizeSessionResponse = getOrFetchSync() + val manifest: FinancialConnectionsSessionManifest = sync.manifest + val authSession = createAuthorizationSession( + institution = requireNotNull(manifest.activeInstitution), + sync = sync + ) + logger.debug("Created auth session ${authSession.id}") + Payload( + authSession = authSession, + institution = requireNotNull(manifest.activeInstitution), + isStripeDirect = manifest.isStripeDirect ?: false + ).also { + // just send loaded event on OAuth flows (prepane). Non-OAuth handled by shim. + val loadedEvent: Loaded? = Loaded(Date()).takeIf { authSession.isOAuth } + postAuthSessionEvent( + authSession.id, + listOfNotNull(launchedEvent, loadedEvent) ) } + }.execute( + // keeps existing payload to prevent showing full-screen loading. + retainValue = SharedPartnerAuthState::payload + ) { + copy( + payload = it, + activeAuthSession = it()?.authSession?.id + ) } private fun launchBrowserIfNonOauth() { @@ -142,22 +140,34 @@ internal class PartnerAuthViewModel @Inject constructor( ) } - private fun logErrors() { + private fun handleErrors() { onAsync( SharedPartnerAuthState::payload, onFail = { - eventTracker.logError( + handleError( extraMessage = "Error fetching payload / posting AuthSession", error = it, - logger = logger, - pane = PANE + pane = PANE, + displayErrorScreen = true ) }, onSuccess = { eventTracker.track(PaneLoaded(PANE)) } ) + onAsync( + SharedPartnerAuthState::authenticationStatus, + onFail = { + handleError( + extraMessage = "Error with authentication status", + error = if (it is FinancialConnectionsError) it else PartnerAuthError(it.message), + pane = PANE, + displayErrorScreen = true + ) + } + ) } fun onLaunchAuthClick() { + setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } viewModelScope.launch { awaitState().payload()?.authSession?.let { postAuthSessionEvent(it.id, AuthSessionEvent.OAuthLaunched(Date())) @@ -201,14 +211,6 @@ internal class PartnerAuthViewModel @Inject constructor( private fun FinancialConnectionsAuthorizationSession.browserReadyUrl(): String? = url?.replaceFirst("stripe-auth://native-redirect/$applicationId/", "") - fun onSelectAnotherBank() { - navigationManager.tryNavigateTo( - Reset(referrer = PANE), - popUpToCurrent = true, - inclusive = true - ) - } - fun onWebAuthFlowFinished( webStatus: WebAuthFlowState ) { @@ -224,7 +226,11 @@ internal class PartnerAuthViewModel @Inject constructor( } WebAuthFlowState.InProgress -> { - setState { copy(authenticationStatus = Loading()) } + setState { + copy( + authenticationStatus = Loading(Status(Action.AUTHENTICATING)) + ) + } } is WebAuthFlowState.Success -> { @@ -279,7 +285,7 @@ internal class PartnerAuthViewModel @Inject constructor( private suspend fun onAuthCancelled(url: String?) { kotlin.runCatching { logger.debug("Auth cancelled, cancelling AuthSession") - setState { copy(authenticationStatus = Loading()) } + setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } val manifest = getOrFetchSync().manifest val authSession = manifest.activeAuthSession eventTracker.track( @@ -339,21 +345,20 @@ internal class PartnerAuthViewModel @Inject constructor( // for OAuth institutions, we remain on the pre-pane, // but create a brand new auth session setState { copy(authenticationStatus = Uninitialized) } - createAuthSession() + recreateAuthSession() } else { // For non-OAuth institutions, navigate to Session cancellation's next pane. postAuthSessionEvent(authSession.id, AuthSessionEvent.Cancel(Date())) navigationManager.tryNavigateTo( - result.nextPane.destination(referrer = PANE), - popUpToCurrent = true, - inclusive = true + route = result.nextPane.destination(referrer = PANE), + popUpTo = PopUpToBehavior.Current(inclusive = true), ) } } private suspend fun completeAuthorizationSession(url: String) { kotlin.runCatching { - setState { copy(authenticationStatus = Loading()) } + setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } val authSession = getOrFetchSync().manifest.activeAuthSession eventTracker.track( AuthSessionUrlReceived( @@ -390,12 +395,6 @@ internal class PartnerAuthViewModel @Inject constructor( } } - fun onEnterDetailsManuallyClick() = navigationManager.tryNavigateTo( - ManualEntry(referrer = PANE), - popUpToCurrent = true, - inclusive = true - ) - // if clicked uri contains an eventName query param, track click event. fun onClickableTextClick(uri: String) = viewModelScope.launch { uriUtils.getQueryParameter(uri, "eventName")?.let { eventName -> @@ -433,6 +432,16 @@ internal class PartnerAuthViewModel @Inject constructor( } } + fun onCancelClick() = viewModelScope.launch { + // set loading state while cancelling the active auth session, and navigate back + setState { copy(authenticationStatus = Loading(value = Status(Action.CANCELLING))) } + runCatching { + val authSession = requireNotNull(getOrFetchSync().manifest.activeAuthSession) + cancelAuthorizationSession(authSession.id) + } + navigationManager.tryNavigateBack() + } + companion object : MavericksViewModelFactory { override fun initialState(viewModelContext: ViewModelContext) = diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt index 469987a1ecb..be7e55237d8 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt @@ -7,7 +7,6 @@ import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.PersistState import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized -import com.stripe.android.financialconnections.model.DataAccessNotice import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest @@ -22,18 +21,24 @@ internal data class SharedPartnerAuthState( val pane: FinancialConnectionsSessionManifest.Pane, val payload: Async = Uninitialized, val viewEffect: ViewEffect? = null, - val authenticationStatus: Async = Uninitialized, + val authenticationStatus: Async = Uninitialized, ) : MavericksState { - val dataAccess: DataAccessNotice? - get() = payload()?.authSession?.display?.text?.oauthPrepane?.dataAccessNotice - data class Payload( val isStripeDirect: Boolean, val institution: FinancialConnectionsInstitution, - val authSession: FinancialConnectionsAuthorizationSession + val authSession: FinancialConnectionsAuthorizationSession, ) + data class AuthenticationStatus( + val action: Action, + ) { + enum class Action { + CANCELLING, + AUTHENTICATING + } + } + val canNavigateBack: Boolean get() = // Authentication running -> don't allow back navigation diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/reset/ResetViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/reset/ResetViewModel.kt index 5d639b7cf77..6fda1929291 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/reset/ResetViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/reset/ResetViewModel.kt @@ -15,6 +15,7 @@ import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator.Message.ClearPartnerWebAuth import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.navigation.PopUpToBehavior import com.stripe.android.financialconnections.navigation.destination import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity import javax.inject.Inject @@ -35,9 +36,8 @@ internal class ResetViewModel @Inject constructor( nativeAuthFlowCoordinator().emit(ClearPartnerWebAuth) eventTracker.track(PaneLoaded(PANE)) navigationManager.tryNavigateTo( - updatedManifest.nextPane.destination(referrer = PANE), - popUpToCurrent = true, - inclusive = true + route = updatedManifest.nextPane.destination(referrer = PANE), + popUpTo = PopUpToBehavior.Current(inclusive = true), ) }.execute { copy(payload = it) } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessContent.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessContent.kt new file mode 100644 index 00000000000..51b4d09dfe1 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessContent.kt @@ -0,0 +1,294 @@ +package com.stripe.android.financialconnections.features.success + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Loading +import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.features.common.LoadingSpinner +import com.stripe.android.financialconnections.features.success.SuccessState.Payload +import com.stripe.android.financialconnections.model.FinancialConnectionsSession +import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview +import com.stripe.android.financialconnections.ui.TextResource +import com.stripe.android.financialconnections.ui.components.AnnotatedText +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar +import com.stripe.android.financialconnections.ui.components.StringAnnotation +import com.stripe.android.financialconnections.ui.components.elevation +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography +import kotlinx.coroutines.delay + +private const val ENTER_TRANSITION_DURATION_MS = 1000 +private const val CHECK_ALPHA_DURATION_MS = 250 +private const val SLIDE_IN_ANIMATION_FRACTION = 4 + +@Composable +internal fun SuccessContent( + completeSessionAsync: Async , + payloadAsync: Async , + onDoneClick: () -> Unit, + onCloseClick: () -> Unit, +) { + SuccessContentInternal( + // Just enabled on Compose Previews: allows to preview the post-animation state. + overrideAnimationForPreview = false, + payloadAsync = payloadAsync, + onCloseClick = onCloseClick, + completeSessionAsync = completeSessionAsync, + onDoneClick = onDoneClick + ) +} + +@Composable +private fun SuccessContentInternal( + overrideAnimationForPreview: Boolean, + payloadAsync: Async , + onCloseClick: () -> Unit, + completeSessionAsync: Async , + onDoneClick: () -> Unit +) { + val scrollState = rememberScrollState() + var showSpinner by remember { mutableStateOf(overrideAnimationForPreview.not()) } + val payload by remember(payloadAsync) { mutableStateOf(payloadAsync()) } + + payload?.let { + if (it.skipSuccessPane.not()) { + LaunchedEffect(true) { + delay(ENTER_TRANSITION_DURATION_MS.toLong()) + showSpinner = false + } + } + } + + FinancialConnectionsScaffold( + topBar = { + FinancialConnectionsTopAppBar( + allowBackNavigation = false, + onCloseClick = onCloseClick, + elevation = scrollState.elevation + ) + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + SpinnerToSuccessAnimation( + customSuccessMessage = payload?.customSuccessMessage, + accountsCount = payload?.accountsCount ?: 0, + showSpinner = showSpinner || payload == null + ) + } + SuccessFooter( + modifier = Modifier.alpha(if (showSpinner) 0f else 1f), + merchantName = payload?.businessName, + loading = completeSessionAsync is Loading, + onDoneClick = onDoneClick + ) + } + } +} + +@Composable +private fun SpinnerToSuccessAnimation( + showSpinner: Boolean, + accountsCount: Int, + customSuccessMessage: TextResource? +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + // Define the animation specs + val enterTransition = fadeIn(animationSpec = tween(ENTER_TRANSITION_DURATION_MS)) + val exitTransition = fadeOut(animationSpec = tween(ENTER_TRANSITION_DURATION_MS)) + + // Delay the appearance of the check icon + val checkAlpha: Float by animateFloatAsState( + targetValue = if (showSpinner) 0f else 1f, + animationSpec = tween( + delayMillis = CHECK_ALPHA_DURATION_MS, + durationMillis = CHECK_ALPHA_DURATION_MS, + easing = LinearEasing, + ), + label = "check_icon_alpha" + ) + + // Fade out loading spinner + AnimatedVisibility( + visible = showSpinner, + enter = enterTransition, + exit = exitTransition, + ) { + LoadingSpinner( + modifier = Modifier.size(56.dp) + ) + } + + // Fade in + slide success content. + AnimatedVisibility( + visible = !showSpinner, + enter = enterTransition + slideInVertically(initialOffsetY = { it / SLIDE_IN_ANIMATION_FRACTION }), + exit = exitTransition + ) { + SuccessCompletedContent( + checkAlpha = checkAlpha, + customSuccessMessage = customSuccessMessage, + accountsCount = accountsCount + ) + } + } +} + +@Composable +private fun SuccessCompletedContent( + checkAlpha: Float, + customSuccessMessage: TextResource?, + accountsCount: Int +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(56.dp) + .background(FinancialConnectionsTheme.colors.iconBrand, CircleShape) + ) { + Icon( + modifier = Modifier.graphicsLayer { alpha = checkAlpha }, + imageVector = Icons.Default.Check, + contentDescription = stringResource(id = R.string.stripe_success_pane_title), + tint = Color.White + ) + } + Text( + stringResource(id = R.string.stripe_success_pane_title), + style = typography.headingXLarge, + textAlign = TextAlign.Center + ) + AnnotatedText( + text = customSuccessMessage ?: TextResource.PluralId( + value = R.plurals.stripe_success_pane_desc, + count = accountsCount, + args = emptyList() + ), + defaultStyle = typography.bodyMedium.copy( + textAlign = TextAlign.Center + ), + annotationStyles = mapOf( + StringAnnotation.BOLD to typography.bodyMediumEmphasized.copy( + textAlign = TextAlign.Center, + ).toSpanStyle() + ), + onClickableTextClick = {} + ) + } +} + +@Composable +private fun SuccessFooter( + modifier: Modifier = Modifier, + loading: Boolean, + merchantName: String?, + onDoneClick: () -> Unit +) { + Box(modifier) { + FinancialConnectionsButton( + loading = loading, + onClick = onDoneClick, + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = when (merchantName) { + null -> stringResource(id = R.string.stripe_success_pane_done) + else -> stringResource(id = R.string.stripe_success_pane_done_with_merchant, merchantName) + } + ) + } + } +} + +@Preview( + group = "Success", + name = "Loading" +) +@Composable +internal fun SuccessScreenPreview( + @PreviewParameter(SuccessPreviewParameterProvider::class) state: SuccessState +) { + FinancialConnectionsPreview { + SuccessContentInternal( + overrideAnimationForPreview = false, + completeSessionAsync = state.completeSession, + payloadAsync = state.payload, + onDoneClick = {}, + onCloseClick = {} + ) + } +} + +@Preview( + group = "Success", + name = "Animation completed" +) +@Composable +internal fun SuccessScreenAnimationCompletedPreview( + @PreviewParameter(SuccessPreviewParameterProvider::class) state: SuccessState +) { + FinancialConnectionsPreview { + SuccessContentInternal( + overrideAnimationForPreview = true, + completeSessionAsync = state.completeSession, + payloadAsync = state.payload, + onDoneClick = {}, + onCloseClick = {} + ) + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessPreviewParameterProvider.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessPreviewParameterProvider.kt new file mode 100644 index 00000000000..3a8d7486303 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessPreviewParameterProvider.kt @@ -0,0 +1,41 @@ +package com.stripe.android.financialconnections.features.success + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.stripe.android.financialconnections.ui.TextResource + +internal class SuccessPreviewParameterProvider : + PreviewParameterProvider { + override val values = sequenceOf( + canonical(), + customMessage() + ) + + private fun canonical() = SuccessState( + payload = Success( + SuccessState.Payload( + skipSuccessPane = false, + accountsCount = 1, + customSuccessMessage = null, + businessName = "Stripe", + ) + ), + completeSession = Uninitialized, + ) + + private fun customMessage() = SuccessState( + payload = Success( + SuccessState.Payload( + skipSuccessPane = false, + accountsCount = 1, + customSuccessMessage = TextResource.Text( + "You can expect micro-deposits to account " + + "••••1234 in 1-2 days and an email with further instructions." + ), + businessName = "Stripe", + ) + ), + completeSession = Uninitialized, + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessScreen.kt index 561168a1833..babfad2aa79 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessScreen.kt @@ -1,399 +1,22 @@ package com.stripe.android.financialconnections.features.success import androidx.activity.compose.BackHandler -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon -import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.airbnb.mvrx.Loading +import androidx.compose.runtime.State import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel -import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.features.common.AccessibleDataCalloutModel -import com.stripe.android.financialconnections.features.common.AccessibleDataCalloutWithAccounts -import com.stripe.android.financialconnections.features.common.LoadingContent -import com.stripe.android.financialconnections.model.FinancialConnectionsAccount -import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane -import com.stripe.android.financialconnections.model.PartnerAccount import com.stripe.android.financialconnections.presentation.parentViewModel -import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview -import com.stripe.android.financialconnections.ui.TextResource -import com.stripe.android.financialconnections.ui.components.AnnotatedText -import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton -import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold -import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar -import com.stripe.android.financialconnections.ui.components.StringAnnotation -import com.stripe.android.financialconnections.ui.components.elevation -import com.stripe.android.financialconnections.ui.theme.Attention100 -import com.stripe.android.financialconnections.ui.theme.Attention50 -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme @Composable internal fun SuccessScreen() { val viewModel: SuccessViewModel = mavericksViewModel() val parentViewModel = parentViewModel() - val state = viewModel.collectAsState() + val state: State = viewModel.collectAsState() BackHandler(enabled = true) {} - state.value.payload()?.let { payload -> - SuccessContent( - accessibleDataModel = payload.accessibleData, - disconnectUrl = payload.disconnectUrl, - accounts = payload.accounts, - institution = payload.institution, - successMessage = payload.successMessage, - loading = state.value.completeSession is Loading, - skipSuccessPane = payload.skipSuccessPane, - onDoneClick = viewModel::onDoneClick, - accountFailedToLinkMessage = payload.accountFailedToLinkMessage, - onLearnMoreAboutDataAccessClick = viewModel::onLearnMoreAboutDataAccessClick, - onDisconnectLinkClick = viewModel::onDisconnectLinkClick - ) { parentViewModel.onCloseNoConfirmationClick(Pane.SUCCESS) } - } + SuccessContent( + completeSessionAsync = state.value.completeSession, + payloadAsync = state.value.payload, + onDoneClick = viewModel::onDoneClick + ) { parentViewModel.onCloseNoConfirmationClick(Pane.SUCCESS) } } - -@Composable -private fun SuccessContent( - accessibleDataModel: AccessibleDataCalloutModel, - disconnectUrl: String, - accounts: List , - institution: FinancialConnectionsInstitution, - successMessage: TextResource, - loading: Boolean, - skipSuccessPane: Boolean, - accountFailedToLinkMessage: TextResource?, - onDoneClick: () -> Unit, - onLearnMoreAboutDataAccessClick: () -> Unit, - onDisconnectLinkClick: () -> Unit, - onCloseClick: () -> Unit, -) { - val scrollState = rememberScrollState() - FinancialConnectionsScaffold( - topBar = { - FinancialConnectionsTopAppBar( - showBack = false, - onCloseClick = onCloseClick, - elevation = scrollState.elevation - ) - } - ) { - if (skipSuccessPane) { - SuccessLoading() - } else { - SuccessLoaded( - scrollState = scrollState, - accounts = accounts, - accessibleDataModel = accessibleDataModel, - disconnectUrl = disconnectUrl, - institution = institution, - loading = loading, - successMessage = successMessage, - accountFailedToLinkMessage = accountFailedToLinkMessage, - onLearnMoreAboutDataAccessClick = onLearnMoreAboutDataAccessClick, - onDisconnectLinkClick = onDisconnectLinkClick, - onDoneClick = onDoneClick - ) - } - } -} - -@Composable -private fun SuccessLoading() { - LoadingContent( - title = stringResource(id = R.string.stripe_success_pane_skip_title), - content = stringResource(id = R.string.stripe_success_pane_skip_desc), - ) -} - -@Composable -@Suppress("LongMethod") -private fun SuccessLoaded( - scrollState: ScrollState, - accounts: List , - accessibleDataModel: AccessibleDataCalloutModel, - disconnectUrl: String, - successMessage: TextResource, - institution: FinancialConnectionsInstitution, - loading: Boolean, - accountFailedToLinkMessage: TextResource?, - onLearnMoreAboutDataAccessClick: () -> Unit, - onDisconnectLinkClick: () -> Unit, - onDoneClick: () -> Unit -) { - val uriHandler = LocalUriHandler.current - Column( - Modifier - .fillMaxSize() - ) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(scrollState) - .padding( - top = 8.dp, - start = 24.dp, - end = 24.dp - ) - ) { - Icon( - modifier = Modifier.size(40.dp), - painter = painterResource(R.drawable.stripe_ic_check_circle), - contentDescription = null, - tint = FinancialConnectionsTheme.colors.textSuccess - ) - Spacer(modifier = Modifier.size(16.dp)) - Text( - modifier = Modifier - .fillMaxWidth(), - text = stringResource(R.string.stripe_success_title), - style = FinancialConnectionsTheme.typography.subtitle - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - modifier = Modifier - .fillMaxWidth(), - text = successMessage.toText().toString(), - style = FinancialConnectionsTheme.typography.body - ) - if (accounts.isNotEmpty()) { - Spacer(modifier = Modifier.size(24.dp)) - AccessibleDataCalloutWithAccounts( - model = accessibleDataModel, - accounts = accounts, - institution = institution, - onLearnMoreClick = { onLearnMoreAboutDataAccessClick() } - ) - } - Spacer(modifier = Modifier.size(12.dp)) - AnnotatedText( - text = TextResource.StringId(R.string.stripe_success_pane_disconnect), - onClickableTextClick = { - onDisconnectLinkClick() - uriHandler.openUri(disconnectUrl) - }, - defaultStyle = FinancialConnectionsTheme.typography.caption.copy( - color = FinancialConnectionsTheme.colors.textSecondary - ), - annotationStyles = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.captionEmphasized - .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textBrand) - ) - ) - Spacer(modifier = Modifier.weight(1f)) - } - SuccessLoadedFooter( - accountFailedToLinkMessage = accountFailedToLinkMessage, - loading = loading, - onDoneClick = onDoneClick - ) - } -} - -@Composable -private fun AccountNotSavedToLinkNotice(message: TextResource) { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(shape = RoundedCornerShape(8.dp)) - .border(color = Attention100, width = 1.dp) - .background(color = Attention50) - .padding(12.dp) - ) { - Row { - Icon( - modifier = Modifier - .size(12.dp) - .offset(y = 2.dp), - painter = painterResource(R.drawable.stripe_ic_warning), - contentDescription = null, - tint = FinancialConnectionsTheme.colors.textAttention - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = message.toText().toString(), - style = FinancialConnectionsTheme.typography.caption.copy( - color = FinancialConnectionsTheme.colors.textSecondary - ) - ) - } - } -} - -@Composable -private fun SuccessLoadedFooter( - loading: Boolean, - accountFailedToLinkMessage: TextResource?, - onDoneClick: () -> Unit -) { - Column( - Modifier.padding( - bottom = 24.dp, - start = 24.dp, - end = 24.dp - ) - ) { - accountFailedToLinkMessage?.let { - AccountNotSavedToLinkNotice(it) - Spacer(modifier = Modifier.size(20.dp)) - } - FinancialConnectionsButton( - loading = loading, - onClick = onDoneClick, - modifier = Modifier - .fillMaxWidth() - ) { - Text(text = stringResource(R.string.stripe_success_pane_done)) - } - } -} - -@Preview( - group = "Success", - name = "Default" -) -@Suppress("LongMethod") -@Composable -internal fun SuccessScreenPreview() { - FinancialConnectionsPreview { - SuccessContent( - accessibleDataModel = AccessibleDataCalloutModel( - businessName = "My business", - permissions = listOf( - FinancialConnectionsAccount.Permissions.PAYMENT_METHOD, - FinancialConnectionsAccount.Permissions.BALANCES, - FinancialConnectionsAccount.Permissions.OWNERSHIP, - FinancialConnectionsAccount.Permissions.TRANSACTIONS - ), - isStripeDirect = true, - isNetworking = false, - dataPolicyUrl = "" - ), - disconnectUrl = "", - accounts = previewAccounts(), - institution = FinancialConnectionsInstitution( - id = "id", - name = "name", - url = "url", - featured = true, - featuredOrder = null, - icon = null, - logo = null, - mobileHandoffCapable = false - ), - successMessage = TextResource.PluralId( - value = R.plurals.stripe_success_pane_link_with_connected_account_name, - count = 2, - args = listOf("ConnectedAccount", "BusinessName") - ), - loading = false, - skipSuccessPane = false, - accountFailedToLinkMessage = null, - onDoneClick = {}, - onLearnMoreAboutDataAccessClick = {}, - onDisconnectLinkClick = {} - ) {} - } -} - -@Composable -@Preview -@Suppress("LongMethod") -internal fun SuccessScreenPreviewFailedToLink() { - FinancialConnectionsPreview { - SuccessContent( - accessibleDataModel = AccessibleDataCalloutModel( - businessName = "My business", - permissions = listOf( - FinancialConnectionsAccount.Permissions.PAYMENT_METHOD, - FinancialConnectionsAccount.Permissions.BALANCES, - FinancialConnectionsAccount.Permissions.OWNERSHIP, - FinancialConnectionsAccount.Permissions.TRANSACTIONS - ), - isStripeDirect = true, - isNetworking = false, - dataPolicyUrl = "" - ), - disconnectUrl = "", - accounts = previewAccounts(), - institution = FinancialConnectionsInstitution( - id = "id", - name = "name", - url = "url", - featured = true, - featuredOrder = null, - icon = null, - logo = null, - mobileHandoffCapable = false - ), - successMessage = TextResource.Text("Hola"), - loading = false, - skipSuccessPane = false, - accountFailedToLinkMessage = TextResource.PluralId( - R.plurals.stripe_success_networking_save_to_link_failed, - 1, - listOf("Random Business") - ), - onDoneClick = {}, - onLearnMoreAboutDataAccessClick = {}, - onDisconnectLinkClick = {} - ) {} - } -} - -@Composable -private fun previewAccounts() = listOf( - PartnerAccount( - authorization = "Authorization", - category = FinancialConnectionsAccount.Category.CASH, - id = "id2", - name = "Account 2 - no acct numbers", - _allowSelection = true, - allowSelectionMessage = "", - subcategory = FinancialConnectionsAccount.Subcategory.SAVINGS, - supportedPaymentMethodTypes = emptyList() - ), - PartnerAccount( - authorization = "Authorization", - category = FinancialConnectionsAccount.Category.CASH, - id = "id3", - name = "Account 3", - _allowSelection = true, - allowSelectionMessage = "", - displayableAccountNumbers = "1234", - subcategory = FinancialConnectionsAccount.Subcategory.CREDIT_CARD, - supportedPaymentMethodTypes = emptyList() - ), - PartnerAccount( - authorization = "Authorization", - category = FinancialConnectionsAccount.Category.CASH, - id = "id4", - name = "Account 4", - _allowSelection = true, - allowSelectionMessage = "", - displayableAccountNumbers = "1234", - subcategory = FinancialConnectionsAccount.Subcategory.CHECKING, - supportedPaymentMethodTypes = emptyList() - ) -) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessViewModel.kt index bca1ab92973..83b37327bb9 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/success/SuccessViewModel.kt @@ -1,6 +1,5 @@ package com.stripe.android.financialconnections.features.success -import androidx.annotation.VisibleForTesting import com.airbnb.mvrx.Async import com.airbnb.mvrx.Loading import com.airbnb.mvrx.MavericksState @@ -10,29 +9,22 @@ import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext import com.stripe.android.core.Logger import com.stripe.android.financialconnections.R -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.ClickDisconnectLink import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.ClickDone -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.ClickLearnMoreDataAccess import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker import com.stripe.android.financialconnections.domain.GetCachedAccounts import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator.Message.Complete -import com.stripe.android.financialconnections.features.common.AccessibleDataCalloutModel -import com.stripe.android.financialconnections.features.consent.FinancialConnectionsUrlResolver -import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution +import com.stripe.android.financialconnections.features.common.useContinueWithMerchantText import com.stripe.android.financialconnections.model.FinancialConnectionsSession import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane -import com.stripe.android.financialconnections.model.PartnerAccount import com.stripe.android.financialconnections.repository.SaveToLinkWithStripeSucceededRepository import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity import com.stripe.android.financialconnections.ui.TextResource -import com.stripe.android.financialconnections.ui.TextResource.PluralId import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") internal class SuccessViewModel @Inject constructor( initialState: SuccessState, getCachedAccounts: GetCachedAccounts, @@ -51,36 +43,24 @@ internal class SuccessViewModel @Inject constructor( val saveToLinkWithStripeSucceeded: Boolean? = saveToLinkWithStripeSucceeded.get() SuccessState.Payload( skipSuccessPane = manifest.skipSuccessPane ?: false, - successMessage = getSuccessMessages( - isLinkWithStripe = manifest.isLinkWithStripe, - isNetworkingUserFlow = manifest.isNetworkingUserFlow, - saveToLinkWithStripeSucceeded = saveToLinkWithStripeSucceeded, - businessName = manifest.businessName, - connectedAccountName = manifest.connectedAccountName, - count = accounts.size - ), - accessibleData = AccessibleDataCalloutModel( - businessName = manifest.businessName, - permissions = manifest.permissions, - isNetworking = manifest.isNetworkingUserFlow == true && saveToLinkWithStripeSucceeded == true, - isStripeDirect = manifest.isStripeDirect ?: false, - dataPolicyUrl = FinancialConnectionsUrlResolver.getDataPolicyUrl(manifest) - ), - accounts = accounts, - institution = manifest.activeInstitution!!, - businessName = manifest.businessName, - disconnectUrl = FinancialConnectionsUrlResolver.getDisconnectUrl(manifest), - accountFailedToLinkMessage = getFailedToLinkMessage( - businessName = manifest.businessName, - saveToLinkWithStripeSucceeded = saveToLinkWithStripeSucceeded, - count = accounts.size - ) + accountsCount = accounts.size, + customSuccessMessage = saveToLinkWithStripeSucceeded?.let { buildCustomMessage(it, accounts.size) }, + // We just want to use the business name in the CTA if the feature is enabled in the manifest. + businessName = manifest.businessName?.takeIf { manifest.useContinueWithMerchantText() }, ) }.execute { copy(payload = it) } } + private fun buildCustomMessage( + saveToLinkWithStripeSucceeded: Boolean, + accountsCount: Int + ): TextResource = when (saveToLinkWithStripeSucceeded) { + true -> TextResource.PluralId(R.plurals.stripe_success_pane_desc_link_success, accountsCount) + false -> TextResource.PluralId(R.plurals.stripe_success_pane_desc_link_error, accountsCount) + } + private fun observeAsyncs() { onAsync( SuccessState::payload, @@ -97,69 +77,6 @@ internal class SuccessViewModel @Inject constructor( ) } - @VisibleForTesting - fun getFailedToLinkMessage( - businessName: String?, - saveToLinkWithStripeSucceeded: Boolean?, - count: Int - ): TextResource? = when { - saveToLinkWithStripeSucceeded != false -> null - businessName != null -> PluralId( - value = R.plurals.stripe_success_networking_save_to_link_failed, - count = count, - args = listOf(businessName) - ) - - else -> PluralId( - R.plurals.stripe_success_pane_networking_save_to_link_failed_no_business, - count, - ) - } - - @VisibleForTesting - internal fun getSuccessMessages( - isLinkWithStripe: Boolean?, - isNetworkingUserFlow: Boolean?, - saveToLinkWithStripeSucceeded: Boolean?, - connectedAccountName: String?, - businessName: String?, - count: Int - ): TextResource = when { - isLinkWithStripe == true || - (isNetworkingUserFlow == true && saveToLinkWithStripeSucceeded == true) -> when { - businessName != null && connectedAccountName != null -> PluralId( - value = R.plurals.stripe_success_pane_link_with_connected_account_name, - count = count, - args = listOf(connectedAccountName, businessName) - ) - - businessName != null -> PluralId( - value = R.plurals.stripe_success_pane_link_with_business_name, - count = count, - args = listOf(businessName) - ) - - else -> PluralId( - R.plurals.stripe_success_pane_link_with_no_business_name, - count, - ) - } - - businessName != null && connectedAccountName != null -> PluralId( - R.plurals.stripe_success_pane_has_connected_account_name, - count = count, - args = listOf(connectedAccountName, businessName) - ) - - businessName != null -> PluralId( - R.plurals.stripe_success_pane_has_business_name, - count, - args = listOf(businessName) - ) - - else -> PluralId(R.plurals.stripe_success_pane_no_business_name, count) - } - fun onDoneClick() = viewModelScope.launch { eventTracker.track(ClickDone(PANE)) setState { copy(completeSession = Loading()) } @@ -170,18 +87,6 @@ internal class SuccessViewModel @Inject constructor( nativeAuthFlowCoordinator().emit(Complete()) } - fun onLearnMoreAboutDataAccessClick() { - viewModelScope.launch { - eventTracker.track(ClickLearnMoreDataAccess(PANE)) - } - } - - fun onDisconnectLinkClick() { - viewModelScope.launch { - eventTracker.track(ClickDisconnectLink(PANE)) - } - } - companion object : MavericksViewModelFactory { override fun create( @@ -207,13 +112,9 @@ internal data class SuccessState( ) : MavericksState { data class Payload( - val accessibleData: AccessibleDataCalloutModel, - val institution: FinancialConnectionsInstitution, - val accounts: List , - val disconnectUrl: String, val businessName: String?, - val skipSuccessPane: Boolean, - val successMessage: TextResource, - val accountFailedToLinkMessage: TextResource? + val customSuccessMessage: TextResource?, + val accountsCount: Int, + val skipSuccessPane: Boolean ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsAuthorizationSession.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsAuthorizationSession.kt index db3452c9e42..8fe20507aa6 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsAuthorizationSession.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsAuthorizationSession.kt @@ -20,7 +20,6 @@ import kotlinx.serialization.Serializable */ @Serializable @Parcelize -@Suppress("ConstructorParameterNaming") internal data class FinancialConnectionsAuthorizationSession( @SerialName(value = "id") diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsInstitution.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsInstitution.kt index 4ba0a96f6b9..1df950235db 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsInstitution.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsInstitution.kt @@ -37,4 +37,25 @@ internal data class FinancialConnectionsInstitution( @SerialName(value = "url") val url: String? = null -) : Parcelable +) : Parcelable { + + /** + * This returns a cleaned up url containing only the root domain. + * Works for up to two-part tlds, but falls apart for longer ones e.g. hello.al.sp.gov.br + * Once the URLs are cleaned up on the backend, won't need this anymore. + */ + val formattedUrl: String + get() = runCatching { + // This match would still have subdomains + val matchResult = Regex("""^(?:https?://)?(?:www\.|[^@\n]+@)?([^:/\n]+)""") + .find(url ?: return "") + val rootUrl = matchResult?.groups?.get(1)?.value ?: return "" + val parts = rootUrl.split('.') + val len = parts.size + return if (len > 2 && parts[len - 2].length <= 3 && parts[len - 1].length <= 2) { + "${parts[len - 3]}.${parts[len - 2]}.${parts[len - 1]}" + } else { + "${parts[len - 2]}.${parts[len - 1]}" + } + }.getOrDefault(url ?: "") +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsSessionManifest.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsSessionManifest.kt index ab849c78107..2d9f6c7935c 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsSessionManifest.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsSessionManifest.kt @@ -47,7 +47,6 @@ import kotlinx.serialization.Serializable * @param paymentMethodType * @param successUrl */ -@Suppress("MaxLineLength") @Serializable @Parcelize internal data class FinancialConnectionsSessionManifest( @@ -243,11 +242,17 @@ internal data class FinancialConnectionsSessionManifest( @SerialName(value = "link_account_picker") LINK_ACCOUNT_PICKER("link_account_picker"), + @SerialName(value = "partner_auth_drawer") + PARTNER_AUTH_DRAWER("partner_auth_drawer"), + @SerialName(value = "networking_save_to_link_verification") NETWORKING_SAVE_TO_LINK_VERIFICATION("networking_save_to_link_verification"), @SerialName(value = "reset") - RESET("reset"); + RESET("reset"), + + @SerialName(value = "exit") + EXIT("exit"); internal object Serializer : EnumIgnoreUnknownSerializer (entries.toTypedArray(), UNEXPECTED_ERROR) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/PartnerAccountsList.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/PartnerAccountsList.kt index 7bd727f9543..25c802ea476 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/PartnerAccountsList.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/PartnerAccountsList.kt @@ -43,7 +43,6 @@ internal data class PartnerAccountsList( */ @Serializable @Parcelize -@Suppress("ConstructorParameterNaming") internal data class PartnerAccount( @SerialName(value = "authorization") @Required val authorization: String, @@ -91,5 +90,6 @@ internal data class PartnerAccount( internal val allowSelection: Boolean get() = _allowSelection ?: true - internal val redactedAccountNumbers: String? get() = displayableAccountNumbers?.let { "••••$it" } + internal val redactedAccountNumbers: String + get() = "••••${displayableAccountNumbers.orEmpty()}" } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/TextUpdate.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/TextUpdate.kt index 0dcd03d2840..96e640cb377 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/TextUpdate.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/TextUpdate.kt @@ -58,7 +58,10 @@ internal data class OauthPrepane( val dataAccessNotice: DataAccessNotice? = null, @SerialName("title") @Serializable(with = MarkdownToHtmlSerializer::class) - val title: String + val title: String, + @SerialName("subtitle") + @Serializable(with = MarkdownToHtmlSerializer::class) + val subtitle: String ) : Parcelable @Serializable @@ -125,7 +128,9 @@ internal data class NetworkingLinkSignupPane( val cta: String, @SerialName("skip_cta") @Serializable(with = MarkdownToHtmlSerializer::class) - val skipCta: String + val skipCta: String, + @SerialName("legal_details_notice") + val legalDetailsNotice: LegalDetailsNotice? = null ) : Parcelable @Serializable @@ -165,47 +170,73 @@ internal data class Bullet( @Serializable @Parcelize internal data class DataAccessNotice( - @SerialName("body") - val body: DataAccessNoticeBody, + @SerialName("icon") + val icon: Image? = null, @SerialName("title") @Serializable(with = MarkdownToHtmlSerializer::class) val title: String, @SerialName("subtitle") @Serializable(with = MarkdownToHtmlSerializer::class) val subtitle: String? = null, + @SerialName("body") + val body: DataAccessNoticeBody, + @SerialName("connected_account_notice") + val connectedAccountNotice: ConnectedAccessNotice? = null, + @SerialName("disclaimer") + @Serializable(with = MarkdownToHtmlSerializer::class) + val disclaimer: String? = null, @SerialName("cta") @Serializable(with = MarkdownToHtmlSerializer::class) val cta: String, - @SerialName("learn_more") - @Serializable(with = MarkdownToHtmlSerializer::class) - val learnMore: String, - @SerialName("connected_account_notice") +) : Parcelable + +@Serializable +@Parcelize +internal data class ConnectedAccessNotice( + @SerialName("subtitle") @Serializable(with = MarkdownToHtmlSerializer::class) - val connectedAccountNotice: String? = null, + val subtitle: String, + @SerialName("body") + val body: DataAccessNoticeBody ) : Parcelable @Serializable @Parcelize internal data class LegalDetailsNotice( - @SerialName("body") - val body: LegalDetailsBody, + @SerialName("icon") + val icon: Image? = null, @SerialName("title") @Serializable(with = MarkdownToHtmlSerializer::class) val title: String, + @SerialName("subtitle") + @Serializable(with = MarkdownToHtmlSerializer::class) + val subtitle: String? = null, + @SerialName("body") + val body: LegalDetailsBody, @SerialName("cta") @Serializable(with = MarkdownToHtmlSerializer::class) val cta: String, - @SerialName("learn_more") + @SerialName("disclaimer") @Serializable(with = MarkdownToHtmlSerializer::class) - val learnMore: String, - + val disclaimer: String? = null ) : Parcelable @Serializable @Parcelize internal data class LegalDetailsBody( - @SerialName("bullets") - val bullets: List + @SerialName("links") + val links: List +) : Parcelable + +@Serializable +@Parcelize +internal data class ServerLink( + @SerialName("title") + @Serializable(with = MarkdownToHtmlSerializer::class) + val title: String, + @SerialName("content") + @Serializable(with = MarkdownToHtmlSerializer::class) + val content: String? = null ) : Parcelable @Serializable diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/Destination.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/Destination.kt index 6d453f482e6..a4fdb780cef 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/Destination.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/Destination.kt @@ -1,5 +1,6 @@ package com.stripe.android.financialconnections.navigation +import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -15,6 +16,8 @@ import com.stripe.android.financialconnections.features.accountpicker.AccountPic import com.stripe.android.financialconnections.features.attachpayment.AttachPaymentScreen import com.stripe.android.financialconnections.features.bankauthrepair.BankAuthRepairScreen import com.stripe.android.financialconnections.features.consent.ConsentScreen +import com.stripe.android.financialconnections.features.error.ErrorScreen +import com.stripe.android.financialconnections.features.exit.ExitModal import com.stripe.android.financialconnections.features.institutionpicker.InstitutionPickerScreen import com.stripe.android.financialconnections.features.linkaccountpicker.LinkAccountPickerScreen import com.stripe.android.financialconnections.features.linkstepupverification.LinkStepUpVerificationScreen @@ -29,6 +32,7 @@ import com.stripe.android.financialconnections.features.reset.ResetScreen import com.stripe.android.financialconnections.features.success.SuccessScreen import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.LinkAccountSessionPaymentAccount.MicrodepositVerificationMethod +import com.stripe.android.financialconnections.navigation.bottomsheet.bottomSheet import com.stripe.android.financialconnections.presentation.parentViewModel internal sealed class Destination( @@ -52,7 +56,7 @@ internal sealed class Destination( if (!paneLaunchedTriggered) { LaunchedEffect(Unit) { viewModel.onPaneLaunched( - referrer = referrer(navBackStackEntry), + referrer = referrer(navBackStackEntry.arguments), pane = navBackStackEntry.destination.pane ) paneLaunchedTriggered = true @@ -97,9 +101,14 @@ internal sealed class Destination( composable = { ConsentScreen() } ) + data object PartnerAuthDrawer : NoArgumentsDestination( + route = Pane.PARTNER_AUTH_DRAWER.value, + composable = { PartnerAuthScreen(inModal = true) } + ) + data object PartnerAuth : NoArgumentsDestination( route = Pane.PARTNER_AUTH.value, - composable = { PartnerAuthScreen() } + composable = { PartnerAuthScreen(inModal = false) } ) data object AccountPicker : NoArgumentsDestination( @@ -129,7 +138,7 @@ internal sealed class Destination( data object NetworkingLinkLoginWarmup : NoArgumentsDestination( route = Pane.NETWORKING_LINK_LOGIN_WARMUP.value, - composable = { NetworkingLinkLoginWarmupScreen() } + composable = { NetworkingLinkLoginWarmupScreen(it) } ) data object NetworkingLinkVerification : NoArgumentsDestination( @@ -159,6 +168,16 @@ internal sealed class Destination( composable = { ResetScreen() } ) + data object Exit : NoArgumentsDestination( + route = Pane.EXIT.value, + composable = { ExitModal(it) } + ) + + data object Error : NoArgumentsDestination( + route = Pane.UNEXPECTED_ERROR.value, + composable = { ErrorScreen() } + ) + data object BankAuthRepair : NoArgumentsDestination( route = Pane.BANK_AUTH_REPAIR.value, composable = { BankAuthRepairScreen() } @@ -178,15 +197,7 @@ internal sealed class Destination( KEY_LAST4 to last4 ) - fun microdeposits(backStackEntry: NavBackStackEntry): MicrodepositVerificationMethod = - backStackEntry.arguments - ?.getString(KEY_MICRODEPOSITS) - ?.let { value -> - MicrodepositVerificationMethod.entries.firstOrNull { it.value == value } - } ?: MicrodepositVerificationMethod.UNKNOWN - - fun last4(backStackEntry: NavBackStackEntry): String? = - backStackEntry.arguments?.getString(KEY_LAST4) + fun last4(args: Bundle?): String? = args?.getString(KEY_LAST4) override fun invoke( referrer: Pane?, @@ -199,7 +210,7 @@ internal sealed class Destination( } companion object { - private fun referrer(entry: NavBackStackEntry): Pane? = entry.arguments + internal fun referrer(args: Bundle?): Pane? = args ?.getString(KEY_REFERRER) ?.let { value -> Pane.entries.firstOrNull { it.value == value } } @@ -233,3 +244,16 @@ internal fun NavGraphBuilder.composable( content = { destination.Composable(navBackStackEntry = it) } ) } + +internal fun NavGraphBuilder.bottomSheet( + destination: Destination, + arguments: List = emptyList(), + deepLinks: List = emptyList(), +) { + bottomSheet( + route = destination.fullRoute, + arguments = arguments, + deepLinks = deepLinks, + content = { destination.Composable(navBackStackEntry = it) } + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/DestinationMappers.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/DestinationMappers.kt index 2bc1d2b6160..ed6f990405c 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/DestinationMappers.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/DestinationMappers.kt @@ -7,6 +7,7 @@ private val paneToDestination = mapOf( Pane.INSTITUTION_PICKER to Destination.InstitutionPicker, Pane.CONSENT to Destination.Consent, Pane.PARTNER_AUTH to Destination.PartnerAuth, + Pane.PARTNER_AUTH_DRAWER to Destination.PartnerAuthDrawer, Pane.ACCOUNT_PICKER to Destination.AccountPicker, Pane.SUCCESS to Destination.Success, Pane.MANUAL_ENTRY to Destination.ManualEntry, @@ -18,6 +19,8 @@ private val paneToDestination = mapOf( Pane.LINK_ACCOUNT_PICKER to Destination.LinkAccountPicker, Pane.LINK_STEP_UP_VERIFICATION to Destination.LinkStepUpVerification, Pane.RESET to Destination.Reset, + Pane.UNEXPECTED_ERROR to Destination.Error, + Pane.EXIT to Destination.Exit, Pane.BANK_AUTH_REPAIR to Destination.BankAuthRepair, Pane.MANUAL_ENTRY_SUCCESS to Destination.ManualEntrySuccess, ) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/NavigationManager.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/NavigationManager.kt index 30f51e7c595..9becc8eeddf 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/NavigationManager.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/NavigationManager.kt @@ -10,19 +10,34 @@ internal interface NavigationManager { fun tryNavigateTo( route: String, - popUpToCurrent: Boolean = false, - inclusive: Boolean = false, + popUpTo: PopUpToBehavior? = null, isSingleTop: Boolean = true, ) + + fun tryNavigateBack() +} + +internal sealed interface PopUpToBehavior { + val inclusive: Boolean + + data class Current( + override val inclusive: Boolean, + ) : PopUpToBehavior + + data class Route( + override val inclusive: Boolean, + val route: String, + ) : PopUpToBehavior } internal sealed class NavigationIntent { data class NavigateTo( val route: String, - val popUpToCurrent: Boolean, - val inclusive: Boolean, + val popUpTo: PopUpToBehavior?, val isSingleTop: Boolean, ) : NavigationIntent() + + data object NavigateBack : NavigationIntent() } internal class NavigationManagerImpl @Inject constructor() : NavigationManager { @@ -32,17 +47,19 @@ internal class NavigationManagerImpl @Inject constructor() : NavigationManager { override fun tryNavigateTo( route: String, - popUpToCurrent: Boolean, - inclusive: Boolean, - isSingleTop: Boolean + popUpTo: PopUpToBehavior?, + isSingleTop: Boolean, ) { _navigationFlow.tryEmit( NavigationIntent.NavigateTo( route = route, - popUpToCurrent = popUpToCurrent, - inclusive = inclusive, + popUpTo = popUpTo, isSingleTop = isSingleTop, ) ) } + + override fun tryNavigateBack() { + _navigationFlow.tryEmit(NavigationIntent.NavigateBack) + } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/BottomSheet.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/BottomSheet.kt new file mode 100644 index 00000000000..142a083cb63 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/BottomSheet.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.stripe.android.financialconnections.navigation.bottomsheet + +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetDefaults +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp + +/** + * Helper function to create a [ModalBottomSheetLayout] from a [BottomSheetNavigator]. + * + * @see [ModalBottomSheetLayout] + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun ModalBottomSheetLayout( + bottomSheetNavigator: BottomSheetNavigator, + modifier: Modifier = Modifier, + sheetShape: Shape = MaterialTheme.shapes.large, + sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, + sheetBackgroundColor: Color = MaterialTheme.colors.surface, + sheetContentColor: Color = contentColorFor(sheetBackgroundColor), + scrimColor: Color = ModalBottomSheetDefaults.scrimColor, + content: @Composable () -> Unit +) { + ModalBottomSheetLayout( + sheetState = bottomSheetNavigator.sheetState, + sheetContent = bottomSheetNavigator.sheetContent, + modifier = modifier, + sheetShape = sheetShape, + sheetElevation = sheetElevation, + sheetBackgroundColor = sheetBackgroundColor, + sheetContentColor = sheetContentColor, + scrimColor = scrimColor, + content = content + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/BottomSheetNavigation.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/BottomSheetNavigation.kt new file mode 100644 index 00000000000..11a0df82cf8 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/BottomSheetNavigation.kt @@ -0,0 +1,249 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.stripe.android.financialconnections.navigation.bottomsheet + +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.SpringSpec +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.navigation.FloatingWindow +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.NavigatorState +import com.stripe.android.financialconnections.navigation.bottomsheet.BottomSheetNavigator.Destination +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.transform + +/** + * The state of a [ModalBottomSheetLayout] that the [BottomSheetNavigator] drives + * + * @param sheetState The sheet state that is driven by the [BottomSheetNavigator] + */ +@OptIn(ExperimentalMaterialApi::class) +@Stable +internal class BottomSheetNavigatorSheetState(internal val sheetState: ModalBottomSheetState) { + /** + * @see ModalBottomSheetState.isVisible + */ + internal val isVisible: Boolean + get() = sheetState.isVisible + + /** + * @see ModalBottomSheetState.currentValue + */ + internal val currentValue: ModalBottomSheetValue + get() = sheetState.currentValue + + /** + * @see ModalBottomSheetState.targetValue + */ + internal val targetValue: ModalBottomSheetValue + get() = sheetState.targetValue +} + +/** + * Create and remember a [BottomSheetNavigator] + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun rememberBottomSheetNavigator( + animationSpec: AnimationSpec = SpringSpec () +): BottomSheetNavigator { + val sheetState = rememberModalBottomSheetState( + ModalBottomSheetValue.Hidden, + animationSpec = animationSpec + ) + return remember { BottomSheetNavigator(sheetState) } +} + +/** + * Navigator that drives a [ModalBottomSheetState] for use of [ModalBottomSheetLayout]s + * with the navigation library. Every destination using this Navigator must set a valid + * [Composable] by setting it directly on an instantiated [Destination] or calling + * [androidx.navigation.compose.material.bottomSheet]. + * + * The [sheetContent] [Composable] will always host the latest entry of the back stack. When + * navigating from a [BottomSheetNavigator.Destination] to another + * [BottomSheetNavigator.Destination], the content of the sheet will be replaced instead of a + * new bottom sheet being shown. + * + * When the sheet is dismissed by the user, the [state]'s [NavigatorState.backStack] will be popped. + * + * The primary constructor is not intended for public use. Please refer to + * [rememberBottomSheetNavigator] instead. + * + * @param sheetState The [ModalBottomSheetState] that the [BottomSheetNavigator] will use to + * drive the sheet state + */ +@OptIn(ExperimentalMaterialApi::class) +@Navigator.Name("BottomSheetNavigator") +internal class BottomSheetNavigator( + internal val sheetState: ModalBottomSheetState +) : Navigator () { + + private var attached by mutableStateOf(false) + + /** + * Get the back stack from the [state]. In some cases, the [sheetContent] might be composed + * before the Navigator is attached, so we specifically return an empty flow if we aren't + * attached yet. + */ + private val backStack: StateFlow > + get() = if (attached) { + state.backStack + } else { + MutableStateFlow(emptyList()) + } + + /** + * Get the transitionsInProgress from the [state]. In some cases, the [sheetContent] might be + * composed before the Navigator is attached, so we specifically return an empty flow if we + * aren't attached yet. + */ + internal val transitionsInProgress: StateFlow
> + get() = if (attached) { + state.transitionsInProgress + } else { + MutableStateFlow(emptySet()) + } + + /** + * Access properties of the [ModalBottomSheetLayout]'s [ModalBottomSheetState] + */ + internal val navigatorSheetState: BottomSheetNavigatorSheetState = BottomSheetNavigatorSheetState(sheetState) + + /** + * A [Composable] function that hosts the current sheet content. This should be set as + * sheetContent of your [ModalBottomSheetLayout]. + */ + internal val sheetContent: @Composable ColumnScope.() -> Unit = { + val saveableStateHolder = rememberSaveableStateHolder() + val transitionsInProgressEntries by transitionsInProgress.collectAsState() + + // The latest back stack entry, retained until the sheet is completely hidden + // While the back stack is updated immediately, we might still be hiding the sheet, so + // we keep the entry around until the sheet is hidden + val retainedEntry by produceState ( + initialValue = null, + key1 = backStack + ) { + backStack + .transform { backStackEntries -> + // Always hide the sheet when the back stack is updated + // Regardless of whether we're popping or pushing, we always want to hide + // the sheet first before deciding whether to re-show it or keep it hidden + try { + sheetState.hide() + } catch (_: CancellationException) { + // We catch but ignore possible cancellation exceptions as we don't want + // them to bubble up and cancel the whole produceState coroutine + } finally { + emit(backStackEntries.lastOrNull()) + } + } + .collect { + value = it + } + } + + if (retainedEntry != null) { + LaunchedEffect(retainedEntry) { + sheetState.show() + } + + BackHandler { + state.popWithTransition(popUpTo = retainedEntry!!, saveState = false) + } + } + + SheetContentHost( + backStackEntry = retainedEntry, + sheetState = sheetState, + saveableStateHolder = saveableStateHolder, + onSheetShown = { + transitionsInProgressEntries.forEach(state::markTransitionComplete) + }, + onSheetDismissed = { backStackEntry -> + // Sheet dismissal can be started through popBackStack in which case we have a + // transition that we'll want to complete + if (transitionsInProgressEntries.contains(backStackEntry)) { + state.markTransitionComplete(backStackEntry) + } + // If there is no transition in progress, the sheet has been dimissed by the + // user (for example by tapping on the scrim or through an accessibility action) + // In this case, we will immediately pop without a transition as the sheet has + // already been hidden + else { + state.pop(popUpTo = backStackEntry, saveState = false) + } + } + ) + } + + override fun onAttach(state: NavigatorState) { + super.onAttach(state) + attached = true + } + + override fun createDestination(): Destination = Destination( + navigator = this, + content = {} + ) + + @SuppressLint("NewApi") // b/187418647 + override fun navigate( + entries: List , + navOptions: NavOptions?, + navigatorExtras: Extras? + ) { + entries.forEach { entry -> + state.pushWithTransition(entry) + } + } + + override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) { + state.popWithTransition(popUpTo, savedState) + } + + /** + * [NavDestination] specific to [BottomSheetNavigator] + */ + @NavDestination.ClassType(Composable::class) + internal class Destination( + navigator: BottomSheetNavigator, + internal val content: @Composable ColumnScope.(NavBackStackEntry) -> Unit + ) : NavDestination(navigator), FloatingWindow +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/NavGraphBuilder.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/NavGraphBuilder.kt new file mode 100644 index 00000000000..81de7da1899 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/NavGraphBuilder.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.stripe.android.financialconnections.navigation.bottomsheet + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.get + +/** + * Add the [content] [Composable] as bottom sheet content to the [NavGraphBuilder] + * + * @param route route for the destination + * @param arguments list of arguments to associate with destination + * @param deepLinks list of deep links to associate with the destinations + * @param content the sheet content at the given destination + */ +@SuppressLint("NewApi") // b/187418647 +internal fun NavGraphBuilder.bottomSheet( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable ColumnScope.(backstackEntry: NavBackStackEntry) -> Unit +) { + addDestination( + BottomSheetNavigator.Destination( + provider[BottomSheetNavigator::class], + content + ).apply { + this.route = route + arguments.forEach { (argumentName, argument) -> + addArgument(argumentName, argument) + } + deepLinks.forEach { deepLink -> + addDeepLink(deepLink) + } + } + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/SheetContentHost.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/SheetContentHost.kt new file mode 100644 index 00000000000..271657e63b7 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/bottomsheet/SheetContentHost.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.stripe.android.financialconnections.navigation.bottomsheet + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.snapshotFlow +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.LocalOwnersProvider +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop + +/** + * Hosts a [BottomSheetNavigator.Destination]'s [NavBackStackEntry] and its + * [BottomSheetNavigator.Destination.content] and provides a [onSheetDismissed] callback. It also + * shows and hides the [ModalBottomSheetLayout] through the [sheetState] when the sheet content + * enters or leaves the composition. + * + * @param backStackEntry The [NavBackStackEntry] holding the [BottomSheetNavigator.Destination], + * or null if there is no [NavBackStackEntry] + * @param sheetState The [ModalBottomSheetState] used to observe and control the sheet visibility + * @param onSheetDismissed Callback when the sheet has been dismissed. Typically, you'll want to + * pop the back stack here. + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun ColumnScope.SheetContentHost( + backStackEntry: NavBackStackEntry?, + sheetState: ModalBottomSheetState, + saveableStateHolder: SaveableStateHolder, + onSheetShown: (entry: NavBackStackEntry) -> Unit, + onSheetDismissed: (entry: NavBackStackEntry) -> Unit, +) { + if (backStackEntry != null) { + val currentOnSheetShown by rememberUpdatedState(onSheetShown) + val currentOnSheetDismissed by rememberUpdatedState(onSheetDismissed) + LaunchedEffect(sheetState, backStackEntry) { + snapshotFlow { sheetState.isVisible } + // We are only interested in changes in the sheet's visibility + .distinctUntilChanged() + // distinctUntilChanged emits the initial value which we don't need + .drop(1) + .collect { visible -> + if (visible) { + currentOnSheetShown(backStackEntry) + } else { + currentOnSheetDismissed(backStackEntry) + } + } + } + backStackEntry.LocalOwnersProvider(saveableStateHolder) { + val content = + (backStackEntry.destination as BottomSheetNavigator.Destination).content + content(backStackEntry) + } + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModel.kt index 7dbaa3734df..5f1d2f2870d 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModel.kt @@ -14,7 +14,6 @@ import com.airbnb.mvrx.compose.mavericksActivityViewModel import com.stripe.android.core.Logger import com.stripe.android.financialconnections.FinancialConnections import com.stripe.android.financialconnections.FinancialConnectionsSheet -import com.stripe.android.financialconnections.R import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AppBackgrounded import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.ClickNavBarBack import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.ClickNavBarClose @@ -27,12 +26,10 @@ import com.stripe.android.financialconnections.di.APPLICATION_ID import com.stripe.android.financialconnections.di.DaggerFinancialConnectionsSheetNativeComponent import com.stripe.android.financialconnections.di.FinancialConnectionsSheetNativeComponent import com.stripe.android.financialconnections.domain.CompleteFinancialConnectionsSession -import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator.Message import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator.Message.Complete.EarlyTerminationCause import com.stripe.android.financialconnections.exception.CustomManualEntryRequiredError -import com.stripe.android.financialconnections.features.common.getBusinessName import com.stripe.android.financialconnections.features.manualentry.isCustomManualEntryError import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityResult import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityResult.Canceled @@ -42,14 +39,13 @@ import com.stripe.android.financialconnections.launcher.FinancialConnectionsShee import com.stripe.android.financialconnections.model.BankAccount import com.stripe.android.financialconnections.model.FinancialConnectionsSession import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane -import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane.NETWORKING_LINK_SIGNUP_PANE +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane.EXIT import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane.UNEXPECTED_ERROR +import com.stripe.android.financialconnections.navigation.Destination import com.stripe.android.financialconnections.navigation.NavigationManager import com.stripe.android.financialconnections.navigation.pane -import com.stripe.android.financialconnections.presentation.FinancialConnectionsSheetNativeState.CloseDialog import com.stripe.android.financialconnections.presentation.FinancialConnectionsSheetNativeViewEffect.Finish import com.stripe.android.financialconnections.presentation.FinancialConnectionsSheetNativeViewEffect.OpenUrl -import com.stripe.android.financialconnections.ui.TextResource import com.stripe.android.financialconnections.utils.UriUtils import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -58,7 +54,6 @@ import kotlinx.parcelize.Parcelize import javax.inject.Inject import javax.inject.Named -@Suppress("LongParameterList", "TooManyFunctions") internal class FinancialConnectionsSheetNativeViewModel @Inject constructor( /** * Exposes parent dagger component (activity viewModel scoped so that it survives config changes) @@ -66,13 +61,12 @@ internal class FinancialConnectionsSheetNativeViewModel @Inject constructor( */ val activityRetainedComponent: FinancialConnectionsSheetNativeComponent, private val nativeAuthFlowCoordinator: NativeAuthFlowCoordinator, - private val getManifest: GetManifest, private val uriUtils: UriUtils, private val completeFinancialConnectionsSession: CompleteFinancialConnectionsSession, private val eventTracker: FinancialConnectionsAnalyticsTracker, private val logger: Logger, + private val navigationManager: NavigationManager, @Named(APPLICATION_ID) private val applicationId: String, - navigationManager: NavigationManager, initialState: FinancialConnectionsSheetNativeState ) : MavericksViewModel (initialState) { @@ -91,6 +85,10 @@ internal class FinancialConnectionsSheetNativeViewModel @Inject constructor( is Message.Complete -> closeAuthFlow( earlyTerminationCause = message.cause ) + + is Message.CloseWithError -> closeAuthFlow( + closeAuthFlowError = message.cause + ) } } } @@ -183,22 +181,8 @@ internal class FinancialConnectionsSheetNativeViewModel @Inject constructor( } fun onCloseWithConfirmationClick(pane: Pane) = viewModelScope.launch { - val manifest = kotlin.runCatching { getManifest() }.getOrNull() - val businessName = manifest?.getBusinessName() - val isNetworkingSignupPane = - manifest?.isNetworkingUserFlow == true && pane == NETWORKING_LINK_SIGNUP_PANE - val description = when { - isNetworkingSignupPane && businessName != null -> TextResource.StringId( - value = R.string.stripe_close_dialog_networking_desc, - args = listOf(businessName) - ) - - else -> TextResource.StringId( - value = R.string.stripe_close_dialog_desc - ) - } eventTracker.track(ClickNavBarClose(pane)) - setState { copy(closeDialog = CloseDialog(description = description)) } + navigationManager.tryNavigateTo(Destination.Exit(referrer = pane)) } fun onBackClick(pane: Pane?) { @@ -218,12 +202,6 @@ internal class FinancialConnectionsSheetNativeViewModel @Inject constructor( closeAuthFlowError = error ) - fun onCloseConfirm() = closeAuthFlow( - closeAuthFlowError = null - ) - - fun onCloseDismiss() = setState { copy(closeDialog = null) } - /** * [NavHost] handles back presses except for when backstack is empty, where it delegates * to the container activity. [onBackPressed] will be triggered on these empty backstack cases. @@ -315,13 +293,16 @@ internal class FinancialConnectionsSheetNativeViewModel @Inject constructor( bankAccountToken != null fun onPaneLaunched(pane: Pane, referrer: Pane?) { - viewModelScope.launch { - eventTracker.track( - PaneLaunched( - referrer = referrer, - pane = pane + // Do not track pane loaded for exit pane as it is not a real pane. + if (pane != EXIT) { + viewModelScope.launch { + eventTracker.track( + PaneLaunched( + referrer = referrer, + pane = pane + ) ) - ) + } } } @@ -374,21 +355,13 @@ internal data class FinancialConnectionsSheetNativeState( @PersistState val firstInit: Boolean, val configuration: FinancialConnectionsSheet.Configuration, - val closeDialog: CloseDialog?, val reducedBranding: Boolean, + val testMode: Boolean, val viewEffect: FinancialConnectionsSheetNativeViewEffect?, val completed: Boolean, val initialPane: Pane ) : MavericksState { - /** - * Payload for the close confirmation dialog, - * which is shown when the user clicks the close button. - */ - data class CloseDialog( - val description: TextResource, - ) - /** * Used by Mavericks to build initial state based on args. */ @@ -396,11 +369,11 @@ internal data class FinancialConnectionsSheetNativeState( constructor(args: FinancialConnectionsSheetNativeActivityArgs) : this( webAuthFlow = WebAuthFlowState.Uninitialized, reducedBranding = args.initialSyncResponse.visual.reducedBranding, + testMode = args.initialSyncResponse.manifest.livemode.not(), firstInit = true, completed = false, initialPane = args.initialSyncResponse.manifest.nextPane, configuration = args.configuration, - closeDialog = null, viewEffect = null ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsUrls.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsUrls.kt index 1f101a387ba..9cbcc862f01 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsUrls.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsUrls.kt @@ -1,5 +1,3 @@ -@file:Suppress("MaxLineLength", "MaximumLineLength") - package com.stripe.android.financialconnections.presentation internal object FinancialConnectionsUrls { diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt index 279820b1dc8..0cb2693fdf4 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt @@ -35,7 +35,7 @@ internal class CoreAuthorizationPendingNetworkingRepairRepository( ) }.getOrNull() - suspend fun set(coreAuthorization: String) = runCatching { + fun set(coreAuthorization: String) = runCatching { logger.debug("core authorization set to $coreAuthorization") setState { copy(coreAuthorization = coreAuthorization) } }.onFailure { diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsErrorRepository.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsErrorRepository.kt new file mode 100644 index 00000000000..5857fcd2311 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsErrorRepository.kt @@ -0,0 +1,35 @@ +package com.stripe.android.financialconnections.repository + +import com.airbnb.mvrx.ExperimentalMavericksApi +import com.airbnb.mvrx.MavericksRepository +import com.airbnb.mvrx.MavericksState +import com.stripe.android.financialconnections.BuildConfig +import kotlinx.coroutines.CoroutineScope + +@OptIn(ExperimentalMavericksApi::class) +internal class FinancialConnectionsErrorRepository( + coroutineScope: CoroutineScope +) : MavericksRepository ( + initialState = State(), + coroutineScope = coroutineScope, + performCorrectnessValidations = BuildConfig.DEBUG, +) { + + suspend fun get() = awaitState().error + + fun set(error: Throwable) { + setState { + copy(error = error) + } + } + + fun clear() { + setState { + copy(error = null) + } + } + + data class State( + val error: Throwable? = null + ) : MavericksState +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt index 8f97215a117..e75caf07994 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt @@ -23,7 +23,6 @@ import java.util.Locale * Repository to centralize reads and writes to the [FinancialConnectionsSessionManifest] * of the current flow. */ -@Suppress("TooManyFunctions") internal interface FinancialConnectionsManifestRepository { /** @@ -199,7 +198,6 @@ internal interface FinancialConnectionsManifestRepository { } } -@Suppress("TooManyFunctions") private class FinancialConnectionsManifestRepositoryImpl( val requestExecutor: FinancialConnectionsRequestExecutor, val apiRequestFactory: ApiRequest.Factory, @@ -258,6 +256,8 @@ private class FinancialConnectionsManifestRepositoryImpl( "emit_events" to true, "locale" to locale.toLanguageTag(), "mobile" to mapOf( + // TODO REMOVE BEFORE MERGING INTEGRATION BRANCH + "forced_authflow_version" to "v3", PARAMS_FULLSCREEN to true, PARAMS_HIDE_CLOSE_BUTTON to true, NetworkConstants.PARAMS_APPLICATION_ID to applicationId diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/CompositionLocal.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/CompositionLocal.kt index 7fd1a541743..fbb3219fa70 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/CompositionLocal.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/CompositionLocal.kt @@ -18,12 +18,14 @@ import com.stripe.android.uicore.image.StripeImageLoader @Composable internal fun FinancialConnectionsPreview( reducedBrandingOverride: Boolean = false, + testMode: Boolean = false, content: @Composable () -> Unit ) { val navController = rememberNavController() FinancialConnectionsTheme { CompositionLocalProvider( LocalNavHostController provides navController, + LocalTestMode provides testMode, LocalReducedBranding provides reducedBrandingOverride, LocalImageLoader provides StripeImageLoader( context = LocalContext.current, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/FinancialConnectionsSheetNativeActivity.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/FinancialConnectionsSheetNativeActivity.kt index 3e9462d2f3a..205a8aa0b37 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/FinancialConnectionsSheetNativeActivity.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/FinancialConnectionsSheetNativeActivity.kt @@ -8,21 +8,22 @@ import androidx.activity.addCallback import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalUriHandler import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import com.airbnb.mvrx.MavericksView @@ -30,19 +31,25 @@ import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.withState import com.stripe.android.core.Logger import com.stripe.android.financialconnections.browser.BrowserManager -import com.stripe.android.financialconnections.features.common.CloseDialog +import com.stripe.android.financialconnections.exception.FinancialConnectionsErrorHandler import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetNativeActivityArgs import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.navigation.Destination import com.stripe.android.financialconnections.navigation.NavigationIntent +import com.stripe.android.financialconnections.navigation.PopUpToBehavior +import com.stripe.android.financialconnections.navigation.bottomSheet +import com.stripe.android.financialconnections.navigation.bottomsheet.BottomSheetNavigator import com.stripe.android.financialconnections.navigation.composable import com.stripe.android.financialconnections.navigation.destination import com.stripe.android.financialconnections.navigation.pane import com.stripe.android.financialconnections.presentation.FinancialConnectionsSheetNativeViewEffect.Finish import com.stripe.android.financialconnections.presentation.FinancialConnectionsSheetNativeViewEffect.OpenUrl import com.stripe.android.financialconnections.presentation.FinancialConnectionsSheetNativeViewModel +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsModalBottomSheetLayout import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.utils.KeyboardController import com.stripe.android.financialconnections.utils.argsOrNull +import com.stripe.android.financialconnections.utils.rememberKeyboardController import com.stripe.android.financialconnections.utils.viewModelLazy import com.stripe.android.uicore.image.StripeImageLoader import kotlinx.coroutines.flow.SharedFlow @@ -65,6 +72,9 @@ internal class FinancialConnectionsSheetNativeActivity : AppCompatActivity(), Ma @Inject lateinit var browserManager: BrowserManager + @Inject + lateinit var errorHandler: FinancialConnectionsErrorHandler + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (args == null) { @@ -73,28 +83,17 @@ internal class FinancialConnectionsSheetNativeActivity : AppCompatActivity(), Ma viewModel.activityRetainedComponent.inject(this) viewModel.onEach { postInvalidate() } onBackPressedDispatcher.addCallback { viewModel.onBackPressed() } + errorHandler.setup(this) setContent { FinancialConnectionsTheme { - Column { - Box(modifier = Modifier.weight(1f)) { - val closeDialog = viewModel.collectAsState { it.closeDialog } - val firstPane = - viewModel.collectAsState { it.initialPane } - val reducedBranding = - viewModel.collectAsState { it.reducedBranding } - closeDialog.value?.let { - CloseDialog( - description = it.description, - onConfirmClick = viewModel::onCloseConfirm, - onDismissClick = viewModel::onCloseDismiss - ) - } - NavHost( - firstPane.value, - reducedBranding.value - ) - } - } + val firstPane by viewModel.collectAsState { it.initialPane } + val reducedBranding by viewModel.collectAsState { it.reducedBranding } + val testMode by viewModel.collectAsState { it.testMode } + NavHost( + initialPane = firstPane, + testMode = testMode, + reducedBranding = reducedBranding + ) } } } @@ -124,23 +123,31 @@ internal class FinancialConnectionsSheetNativeActivity : AppCompatActivity(), Ma } } - @Suppress("LongMethod") @Composable fun NavHost( initialPane: Pane, + testMode: Boolean, reducedBranding: Boolean ) { val context = LocalContext.current - val navController = rememberNavController() - val uriHandler = remember { CustomTabUriHandler(context, browserManager) } val initialDestination = remember(initialPane) { initialPane.destination } + val sheetState = rememberModalBottomSheetState( + ModalBottomSheetValue.Hidden, + skipHalfExpanded = true + ) + + val bottomSheetNavigator = remember { BottomSheetNavigator(sheetState) } + val navController = rememberNavController(bottomSheetNavigator) + val keyboardController = rememberKeyboardController() + PaneBackgroundEffects(navController) - NavigationEffects(viewModel.navigationFlow, navController) + NavigationEffects(viewModel.navigationFlow, navController, keyboardController) CompositionLocalProvider( LocalReducedBranding provides reducedBranding, + LocalTestMode provides testMode, LocalNavHostController provides navController, LocalImageLoader provides imageLoader, LocalUriHandler provides uriHandler @@ -149,26 +156,33 @@ internal class FinancialConnectionsSheetNativeActivity : AppCompatActivity(), Ma viewModel.onBackClick(navController.currentDestination?.pane) if (navController.popBackStack().not()) viewModel.onBackPressed() } - NavHost( - navController, - startDestination = initialDestination.fullRoute, + FinancialConnectionsModalBottomSheetLayout( + bottomSheetNavigator = bottomSheetNavigator, ) { - composable(Destination.Consent) - composable(Destination.ManualEntry) - composable(Destination.PartnerAuth) - composable(Destination.InstitutionPicker) - composable(Destination.AccountPicker) - composable(Destination.Success) - composable(Destination.Reset) - composable(Destination.AttachLinkedPaymentAccount) - composable(Destination.NetworkingLinkSignup) - composable(Destination.NetworkingLinkLoginWarmup) - composable(Destination.NetworkingLinkVerification) - composable(Destination.NetworkingSaveToLinkVerification) - composable(Destination.LinkAccountPicker) - composable(Destination.BankAuthRepair) - composable(Destination.LinkStepUpVerification) - composable(Destination.ManualEntrySuccess) + NavHost( + navController, + startDestination = initialDestination.fullRoute, + ) { + composable(Destination.Consent) + composable(Destination.ManualEntry) + composable(Destination.PartnerAuth) + bottomSheet(Destination.PartnerAuthDrawer) + bottomSheet(Destination.Exit) + composable(Destination.InstitutionPicker) + composable(Destination.AccountPicker) + composable(Destination.Success) + composable(Destination.Reset) + composable(Destination.Error) + composable(Destination.AttachLinkedPaymentAccount) + composable(Destination.NetworkingLinkSignup) + bottomSheet(Destination.NetworkingLinkLoginWarmup) + composable(Destination.NetworkingLinkVerification) + composable(Destination.NetworkingSaveToLinkVerification) + composable(Destination.LinkAccountPicker) + composable(Destination.BankAuthRepair) + composable(Destination.LinkStepUpVerification) + composable(Destination.ManualEntrySuccess) + } } } } @@ -209,7 +223,8 @@ internal class FinancialConnectionsSheetNativeActivity : AppCompatActivity(), Ma @Composable fun NavigationEffects( navigationChannel: SharedFlow , - navHostController: NavHostController + navHostController: NavHostController, + keyboardController: KeyboardController, ) { val activity = (LocalContext.current as? Activity) LaunchedEffect(activity, navHostController, navigationChannel) { @@ -217,20 +232,29 @@ internal class FinancialConnectionsSheetNativeActivity : AppCompatActivity(), Ma if (activity?.isFinishing == true) { return@onEach } + + keyboardController.dismiss() + when (intent) { is NavigationIntent.NavigateTo -> { val from: String? = navHostController.currentDestination?.route val destination: String = intent.route + if (destination.isNotEmpty() && destination != from) { logger.debug("Navigating from $from to $destination") navHostController.navigate(destination) { launchSingleTop = intent.isSingleTop - if (from != null && intent.popUpToCurrent) { - popUpTo(from) { inclusive = true } + + if (intent.popUpTo != null) { + apply(from, intent.popUpTo) } } } } + + NavigationIntent.NavigateBack -> { + navHostController.popBackStack() + } } }.launchIn(this) } @@ -249,6 +273,10 @@ internal val LocalReducedBranding = staticCompositionLocalOf { error("No ReducedBranding provided") } +internal val LocalTestMode = staticCompositionLocalOf { + error("No TestMode provided") +} + internal val LocalImageLoader = staticCompositionLocalOf { error("No ImageLoader provided") } @@ -285,3 +313,19 @@ private class ActivityVisibilityObserver( } } } + +private fun NavOptionsBuilder.apply( + currentRoute: String?, + popUpTo: PopUpToBehavior, +) { + val popUpToRoute = when (popUpTo) { + is PopUpToBehavior.Current -> currentRoute + is PopUpToBehavior.Route -> popUpTo.route + } + + if (popUpToRoute != null) { + popUpTo(popUpToRoute) { + inclusive = popUpTo.inclusive + } + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/HandleClickableUrl.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/HandleClickableUrl.kt new file mode 100644 index 00000000000..3662eb917d6 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/HandleClickableUrl.kt @@ -0,0 +1,36 @@ +package com.stripe.android.financialconnections.ui + +import android.webkit.URLUtil +import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.Click +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane +import com.stripe.android.financialconnections.utils.UriUtils +import javax.inject.Inject + +internal class HandleClickableUrl @Inject constructor( + private val uriUtils: UriUtils, + private val eventTracker: FinancialConnectionsAnalyticsTracker, + private val logger: Logger, +) { + + suspend operator fun invoke( + currentPane: Pane, + uri: String, + onNetworkUrlClicked: (String) -> Unit, + knownDeeplinkActions: Map Unit> + ) { + uriUtils.getQueryParameter(uri, "eventName")?.let { eventName -> + eventTracker.track(Click(eventName, pane = currentPane)) + } + when { + URLUtil.isNetworkUrl(uri) -> onNetworkUrlClicked(uri) + else -> { + val clickedEntry = knownDeeplinkActions.entries.firstOrNull { (key, _) -> + uriUtils.compareSchemeAuthorityAndPath(key, uri) + } + clickedEntry?.value?.invoke() ?: logger.error("Unrecognized clickable text: $uri") + } + } + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/BottomSheet.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/BottomSheet.kt new file mode 100644 index 00000000000..a814ecb6fbd --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/BottomSheet.kt @@ -0,0 +1,41 @@ +package com.stripe.android.financialconnections.ui.components + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.stripe.android.financialconnections.navigation.bottomsheet.BottomSheetNavigator +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.Neutral900 + +@Composable +internal fun FinancialConnectionsModalBottomSheetLayout( + sheetContent: @Composable ColumnScope.() -> Unit, + sheetState: ModalBottomSheetState, + content: @Composable () -> Unit +) { + ModalBottomSheetLayout( + sheetState = sheetState, + sheetBackgroundColor = colors.backgroundSurface, + sheetShape = RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp), + scrimColor = Neutral900.copy(alpha = 0.32f), + sheetContent = sheetContent, + content = content + ) +} + +@Composable +internal fun FinancialConnectionsModalBottomSheetLayout( + bottomSheetNavigator: BottomSheetNavigator, + content: @Composable () -> Unit +) { + com.stripe.android.financialconnections.navigation.bottomsheet.ModalBottomSheetLayout( + bottomSheetNavigator = bottomSheetNavigator, + sheetBackgroundColor = colors.backgroundSurface, + sheetShape = RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp), + scrimColor = Neutral900.copy(alpha = 0.32f), + content = content, + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Button.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Button.kt index 7a4b9c98bf0..cc485f08099 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Button.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Button.kt @@ -1,14 +1,16 @@ -@file:Suppress("ktlint:filename") - package com.stripe.android.financialconnections.ui.components +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.R +import android.view.HapticFeedbackConstants.CONFIRM +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -18,7 +20,7 @@ import androidx.compose.material.Button import androidx.compose.material.ButtonColors import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults.buttonColors -import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ButtonElevation import androidx.compose.material.ProvideTextStyle import androidx.compose.material.Text import androidx.compose.material.ripple.LocalRippleTheme @@ -26,20 +28,32 @@ import androidx.compose.material.ripple.RippleAlpha import androidx.compose.material.ripple.RippleTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.stripe.android.financialconnections.features.common.LoadingSpinner import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton.Type import com.stripe.android.financialconnections.ui.theme.Brand400 -import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography +import com.stripe.android.financialconnections.ui.theme.Neutral0 import com.stripe.android.financialconnections.ui.theme.Neutral50 +private val DefaultSpinnerHeight = 24.dp + @Composable internal fun FinancialConnectionsButton( onClick: () -> Unit, @@ -50,43 +64,59 @@ internal fun FinancialConnectionsButton( loading: Boolean = false, content: @Composable (RowScope.() -> Unit) ) { + val view = LocalView.current + val density = LocalDensity.current + val multipleEventsCutter = remember { MultipleEventsCutter.get() } + var spinnerHeight by remember { mutableStateOf(DefaultSpinnerHeight) } + + val loadingIndicatorAlpha by animateFloatAsState( + targetValue = if (loading) 1f else 0f, + label = "LoadingIndicatorAlpha", + ) + CompositionLocalProvider(LocalRippleTheme provides type.rippleTheme()) { Button( onClick = { multipleEventsCutter.processEvent { - if (loading.not()) onClick() + if (loading.not()) { + if (SDK_INT >= R) view.performHapticFeedback(CONFIRM) + onClick() + } } }, modifier = modifier, - elevation = ButtonDefaults.elevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - disabledElevation = 0.dp, - hoveredElevation = 0.dp, - focusedElevation = 0.dp, - ), + elevation = type.elevation(), enabled = enabled, shape = RoundedCornerShape(size = size.radius), - contentPadding = size.paddingValues(), + contentPadding = PaddingValues(0.dp), colors = type.buttonColors(), content = { ProvideTextStyle( - value = FinancialConnectionsTheme.typography.bodyEmphasized.copy( + value = typography.labelLargeEmphasized.copy( // material button adds letter spacing internally, this removes it. letterSpacing = 0.sp ) ) { - Row { - if (loading) { - CircularProgressIndicator( - strokeWidth = 4.dp, - modifier = Modifier.size(21.dp), - color = colors.textWhite - ) - Spacer(modifier = Modifier.size(8.dp)) - } - content() + Box(contentAlignment = Alignment.Center) { + Row( + modifier = Modifier + .alpha(1f - loadingIndicatorAlpha) + .padding(size.paddingValues()) + .onSizeChanged { + // Set the spinner to the same height as the label, + // so we avoid visual jitter. + spinnerHeight = with(density) { it.height.toDp() } + }, + content = content, + ) + + LoadingSpinner( + strokeWidth = 2.dp, + modifier = Modifier + .size(spinnerHeight) + .alpha(loadingIndicatorAlpha), + ) } } } @@ -97,9 +127,8 @@ internal fun FinancialConnectionsButton( private fun Type.rippleTheme() = object : RippleTheme { @Composable override fun defaultColor() = when (this@rippleTheme) { - Type.Primary -> Color.White - Type.Secondary -> colors.textSecondary - Type.Critical -> Color.White + Type.Primary -> Neutral0 + Type.Secondary -> colors.textDefault } @Composable @@ -117,46 +146,43 @@ internal object FinancialConnectionsButton { abstract fun buttonColors(): ButtonColors abstract fun rippleColor(): Color + @Composable + abstract fun elevation(): ButtonElevation + data object Primary : Type() { @Composable - override fun buttonColors(): ButtonColors { - return buttonColors( - backgroundColor = colors.textBrand, - contentColor = colors.textWhite, - disabledBackgroundColor = colors.textBrand, - disabledContentColor = colors.textWhite.copy(alpha = 0.3f) - ) - } + override fun buttonColors(): ButtonColors = buttonColors( + backgroundColor = colors.iconBrand, + contentColor = colors.textWhite, + disabledBackgroundColor = colors.iconBrand, + disabledContentColor = colors.textWhite.copy(alpha = 0.4f) + ) override fun rippleColor(): Color = Brand400 + + @Composable + override fun elevation(): ButtonElevation = ButtonDefaults.elevation() } data object Secondary : Type() { @Composable - override fun buttonColors(): ButtonColors { - return buttonColors( - backgroundColor = colors.backgroundContainer, - contentColor = colors.textPrimary, - disabledBackgroundColor = colors.backgroundContainer, - disabledContentColor = colors.textPrimary.copy(alpha = 0.12f) - ) - } + override fun buttonColors(): ButtonColors = buttonColors( + backgroundColor = Neutral50, + contentColor = colors.textDefault, + disabledBackgroundColor = Neutral50, + disabledContentColor = colors.textDefault.copy(alpha = 0.4f) + ) override fun rippleColor(): Color = Neutral50 - } - data object Critical : Type() { @Composable - override fun buttonColors(): ButtonColors { - return buttonColors( - backgroundColor = colors.textCritical, - contentColor = colors.textWhite, - disabledBackgroundColor = colors.textCritical.copy(alpha = 0.12f), - disabledContentColor = colors.textPrimary.copy(alpha = 0.12f) - ) - } - - override fun rippleColor(): Color = Neutral50 + override fun elevation(): ButtonElevation = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + disabledElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp, + ) } } @@ -166,18 +192,6 @@ internal object FinancialConnectionsButton { abstract fun paddingValues(): PaddingValues abstract val radius: Dp - data object Pill : Size() { - override val radius: Dp = 4.dp - - @Composable - override fun paddingValues(): PaddingValues = PaddingValues( - start = 8.dp, - top = 4.dp, - end = 8.dp, - bottom = 4.dp - ) - } - data object Regular : Size() { override val radius: Dp = 12.dp diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Scaffold.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Scaffold.kt index a1e3b688249..f23c840700e 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Scaffold.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Scaffold.kt @@ -1,5 +1,3 @@ -@file:Suppress("ktlint:filename") - package com.stripe.android.financialconnections.ui.components import androidx.compose.foundation.layout.PaddingValues @@ -14,7 +12,7 @@ internal fun FinancialConnectionsScaffold( ) { Scaffold( backgroundColor = FinancialConnectionsTheme.colors.backgroundSurface, - contentColor = FinancialConnectionsTheme.colors.textPrimary, + contentColor = FinancialConnectionsTheme.colors.textDefault, topBar = topBar, content = content ) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TestModeBanner.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TestModeBanner.kt new file mode 100644 index 00000000000..614752eb89c --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TestModeBanner.kt @@ -0,0 +1,103 @@ +package com.stripe.android.financialconnections.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme + +@Composable +internal fun TestModeBanner( + enabled: Boolean, + buttonLabel: String, + onButtonClick: () -> Unit, + modifier: Modifier = Modifier, + description: String = stringResource(R.string.stripe_verification_inTestMode), +) { + val contentAlpha = if (enabled) ContentAlpha.high else ContentAlpha.disabled + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .fillMaxWidth() + .background( + color = FinancialConnectionsTheme.colors.backgroundCaution, + shape = RoundedCornerShape(12.dp), + ) + .alpha(contentAlpha) + .padding( + vertical = 8.dp, + horizontal = 16.dp, + ), + ) { + Image( + painter = painterResource(R.drawable.stripe_ic_info), + colorFilter = ColorFilter.tint( + color = FinancialConnectionsTheme.colors.iconCaution, + ), + contentDescription = null, + ) + + Text( + text = description, + style = FinancialConnectionsTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + + Text( + text = buttonLabel, + color = FinancialConnectionsTheme.colors.textBrand, + style = FinancialConnectionsTheme.typography.bodyMediumEmphasized, + modifier = Modifier.clickable(enabled = enabled, onClick = onButtonClick) + ) + } +} + +@Preview( + group = "Test Mode Banner", + name = "Enabled", +) +@Composable +internal fun TestModeBannerPreviewEnabled() { + FinancialConnectionsTheme { + TestModeBanner( + enabled = true, + buttonLabel = "Use test code", + onButtonClick = {}, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Preview( + group = "Test Mode Banner", + name = "Disabled", +) +@Composable +internal fun TestModeBannerPreviewDisabled() { + FinancialConnectionsTheme { + TestModeBanner( + enabled = false, + buttonLabel = "Use test code", + onButtonClick = {}, + modifier = Modifier.padding(16.dp), + ) + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Text.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Text.kt index e9ac028cce9..e10bc72b6e9 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Text.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/Text.kt @@ -1,5 +1,3 @@ -@file:Suppress("MatchingDeclarationName") - package com.stripe.android.financialconnections.ui.components import android.graphics.Typeface @@ -21,6 +19,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.core.text.getSpans import com.stripe.android.financialconnections.ui.TextResource @@ -33,14 +32,14 @@ internal fun AnnotatedText( defaultStyle: TextStyle, modifier: Modifier = Modifier, annotationStyles: Map = mapOf( - StringAnnotation.CLICKABLE to FinancialConnectionsTheme.typography.bodyEmphasized + StringAnnotation.CLICKABLE to defaultStyle .toSpanStyle() - .copy(color = FinancialConnectionsTheme.colors.textBrand) + .copy(textDecoration = TextDecoration.Underline) ), maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip ) { - val pressedColor = FinancialConnectionsTheme.colors.textPrimary + val pressedColor = FinancialConnectionsTheme.colors.textDefault var pressedAnnotation: String? by remember { mutableStateOf(null) } val resource = annotatedStringResource( resource = text, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TextField.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TextField.kt index 2354ac379e2..3601170ef86 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TextField.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TextField.kt @@ -1,19 +1,19 @@ -@file:OptIn(ExperimentalMaterialApi::class) -@file:Suppress("ktlint:filename") - package com.stripe.android.financialconnections.ui.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ContentAlpha import androidx.compose.material.ExposedDropdownMenuDefaults.outlinedTextFieldColors import androidx.compose.material.OutlinedTextField import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.shadow import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -22,68 +22,77 @@ import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsThem @Composable internal fun FinancialConnectionsOutlinedTextField( - value: TextFieldValue, + value: String, + enabled: Boolean, modifier: Modifier = Modifier, - onValueChange: (TextFieldValue) -> Unit, + onValueChange: (String) -> Unit, readOnly: Boolean = false, isError: Boolean = false, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, placeholder: @Composable (() -> Unit)? = null, visualTransformation: VisualTransformation = VisualTransformation.None, trailingIcon: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null, label: @Composable (() -> Unit)? = null ) { + val contentAlpha = if (enabled) ContentAlpha.high else ContentAlpha.disabled + val shape = RoundedCornerShape(12.dp) OutlinedTextField( - shape = RoundedCornerShape(8.dp), - modifier = modifier.fillMaxWidth(), + enabled = enabled, + shape = shape, + modifier = modifier + .fillMaxWidth() + .alpha(contentAlpha) + .shadow(1.dp, shape), leadingIcon = leadingIcon, trailingIcon = trailingIcon, placeholder = placeholder, + maxLines = 1, visualTransformation = visualTransformation, keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, readOnly = readOnly, isError = isError, value = value, colors = outlinedTextFieldColors( - focusedBorderColor = FinancialConnectionsTheme.colors.textBrand, - unfocusedBorderColor = FinancialConnectionsTheme.colors.borderDefault, + backgroundColor = FinancialConnectionsTheme.colors.backgroundSurface, + focusedBorderColor = FinancialConnectionsTheme.colors.borderBrand, + unfocusedBorderColor = FinancialConnectionsTheme.colors.border, disabledBorderColor = FinancialConnectionsTheme.colors.textDisabled, - unfocusedLabelColor = FinancialConnectionsTheme.colors.textSecondary, + unfocusedLabelColor = FinancialConnectionsTheme.colors.textSubdued, errorBorderColor = FinancialConnectionsTheme.colors.textCritical, - focusedLabelColor = FinancialConnectionsTheme.colors.textBrand, - cursorColor = FinancialConnectionsTheme.colors.textBrand, + focusedLabelColor = FinancialConnectionsTheme.colors.textSubdued, + cursorColor = FinancialConnectionsTheme.colors.borderBrand, errorCursorColor = FinancialConnectionsTheme.colors.textCritical, errorLabelColor = FinancialConnectionsTheme.colors.textCritical, errorTrailingIconColor = FinancialConnectionsTheme.colors.textCritical, - trailingIconColor = FinancialConnectionsTheme.colors.borderDefault, - focusedTrailingIconColor = FinancialConnectionsTheme.colors.borderDefault + trailingIconColor = FinancialConnectionsTheme.colors.iconDefault, + focusedTrailingIconColor = FinancialConnectionsTheme.colors.iconDefault ), onValueChange = onValueChange, label = label ) } -internal fun TextFieldValue.filtered(predicate: (Char) -> Boolean): TextFieldValue = copy( - text = text.filter(predicate), - selection = selection.adjustForFilter(text, predicate), - composition = composition?.adjustForFilter(text, predicate), -) - -private fun TextRange.adjustForFilter( - text: String, - predicate: (Char) -> Boolean -): TextRange = TextRange( - start = text.subSequence(0, start).count(predicate), - end = text.subSequence(0, end).count(predicate), -) - @Preview(group = "Components", name = "TextField - idle") @Composable internal fun FinancialConnectionsOutlinedTextFieldPreview() { FinancialConnectionsPreview { - Column { - FinancialConnectionsOutlinedTextField(value = TextFieldValue("test"), onValueChange = {}) - } + FinancialConnectionsScaffold( + topBar = { FinancialConnectionsTopAppBar { } }, + content = { + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + FinancialConnectionsOutlinedTextField( + value = "test", + enabled = true, + onValueChange = {} + ) + } + } + ) } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TopAppBar.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TopAppBar.kt index bcc48753c59..52a2cdef1c2 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TopAppBar.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/components/TopAppBar.kt @@ -1,77 +1,187 @@ -@file:Suppress("ktlint:filename") - package com.stripe.android.financialconnections.ui.components +import androidx.activity.OnBackPressedDispatcher import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.AppBarDefaults import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavHostController import com.stripe.android.financialconnections.R +import com.stripe.android.financialconnections.navigation.bottomsheet.BottomSheetNavigator import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview import com.stripe.android.financialconnections.ui.LocalNavHostController import com.stripe.android.financialconnections.ui.LocalReducedBranding +import com.stripe.android.financialconnections.ui.LocalTestMode +import com.stripe.android.financialconnections.ui.theme.Attention200 import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme +import com.stripe.android.financialconnections.utils.KeyboardController +import com.stripe.android.financialconnections.utils.rememberKeyboardController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private val LOGO_WIDTH = 50.dp +private val LOGO_HEIGHT = 20.dp +private val PILL_HORIZONTAL_PADDING = 4.dp +private val PILL_VERTICAL_PADDING = 2.dp +private const val PILL_RADIUS = 8f @Composable internal fun FinancialConnectionsTopAppBar( hideStripeLogo: Boolean = LocalReducedBranding.current, + testMode: Boolean = LocalTestMode.current, elevation: Dp = 0.dp, - showBack: Boolean = true, + allowBackNavigation: Boolean = true, onCloseClick: () -> Unit ) { - val localBackPressed = LocalOnBackPressedDispatcherOwner.current - ?.onBackPressedDispatcher + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current + val localBackPressed = onBackPressedDispatcher?.onBackPressedDispatcher + val navController = LocalNavHostController.current + val canShowBackIcon by navController.collectCanShowBackIconAsState() + + val keyboardController = rememberKeyboardController() + val scope = rememberCoroutineScope() + TopAppBar( - title = if (hideStripeLogo) { - { /* Empty content */ } - } else { - { - Icon( - painter = painterResource(id = R.drawable.stripe_logo), - contentDescription = null // decorative element - ) - } + title = { + Title( + hideStripeLogo = hideStripeLogo, + testmode = testMode + ) }, elevation = elevation, - navigationIcon = if (navController.previousBackStackEntry != null && showBack) { + navigationIcon = if (canShowBackIcon && allowBackNavigation) { { - IconButton(onClick = { localBackPressed?.onBackPressed() }) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = "Back icon", - tint = FinancialConnectionsTheme.colors.textSecondary - ) - } + BackButton( + scope = scope, + keyboardController = keyboardController, + localBackPressed = localBackPressed + ) } } else { null }, actions = { - IconButton(onClick = onCloseClick) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Close icon", - tint = FinancialConnectionsTheme.colors.textSecondary - ) - } + CloseButton( + scope = scope, + keyboardController = keyboardController, + onCloseClick = onCloseClick + ) }, backgroundColor = FinancialConnectionsTheme.colors.textWhite, contentColor = FinancialConnectionsTheme.colors.textBrand ) } +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun BackButton( + scope: CoroutineScope, + keyboardController: KeyboardController, + localBackPressed: OnBackPressedDispatcher? +) { + IconButton( + onClick = { + scope.launch { + keyboardController.dismiss() + localBackPressed?.onBackPressed() + } + }, + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back icon", + tint = FinancialConnectionsTheme.colors.iconDefault, + modifier = Modifier + .testTag("top-app-bar-back-button") + .semantics { testTagsAsResourceId = true } + ) + } +} + +@Composable +private fun CloseButton( + scope: CoroutineScope, + keyboardController: KeyboardController, + onCloseClick: () -> Unit +) { + IconButton( + onClick = { + scope.launch { + keyboardController.dismiss() + onCloseClick() + } + } + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close icon", + tint = FinancialConnectionsTheme.colors.iconDefault + ) + } +} + +@Composable +private fun Title(hideStripeLogo: Boolean, testmode: Boolean) = Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) +) { + if (hideStripeLogo.not()) { + Icon( + modifier = Modifier.size(width = LOGO_WIDTH, height = LOGO_HEIGHT), + painter = painterResource(id = R.drawable.stripe_logo), + contentDescription = null // decorative element + ) + } + // show a test mode pill if in test mode + if (testmode) { + Text( + modifier = Modifier + .drawBehind { + drawRoundRect( + color = Attention200, + cornerRadius = CornerRadius(PILL_RADIUS) + ) + } + .padding(vertical = PILL_VERTICAL_PADDING, horizontal = PILL_HORIZONTAL_PADDING), + text = "Test", + style = FinancialConnectionsTheme.typography.labelMediumEmphasized, + color = FinancialConnectionsTheme.colors.textWhite + ) + } +} + /** * calculates toolbar elevation based on [ScrollState] */ @@ -93,6 +203,25 @@ internal val LazyListState.elevation: Dp AppBarDefaults.TopAppBarElevation } +@Composable +private fun NavHostController.collectCanShowBackIconAsState(): State { + val canShowBackIcon = remember { mutableStateOf(false) } + DisposableEffect(Unit) { + val listener = NavController.OnDestinationChangedListener { controller, destination, _ -> + if (destination.navigatorName == BottomSheetNavigator::class.java.simpleName) { + // We're looking at a bottom sheet, so don't change the back button + } else { + canShowBackIcon.value = controller.previousBackStackEntry != null + } + } + addOnDestinationChangedListener(listener) + onDispose { + removeOnDestinationChangedListener(listener) + } + } + return canShowBackIcon +} + @Preview(group = "Components", name = "TopAppBar") @Composable internal fun TopAppBarNoStripeLogoPreview() { diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/sdui/ServerDrivenUi.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/sdui/ServerDrivenUi.kt index 7bedeaabd43..aee7877be6d 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/sdui/ServerDrivenUi.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/sdui/ServerDrivenUi.kt @@ -1,10 +1,10 @@ -@file:Suppress("MatchingDeclarationName") - package com.stripe.android.financialconnections.ui.sdui import android.os.Build import android.text.Html import android.text.Spanned +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.stripe.android.financialconnections.model.Bullet import com.stripe.android.financialconnections.ui.ImageResource import com.stripe.android.financialconnections.ui.TextResource @@ -34,3 +34,6 @@ internal fun fromHtml(source: String): Spanned { Html.fromHtml(source) } } + +@Composable +internal fun rememberHtml(html: String): TextResource.Text = remember(html) { TextResource.Text(fromHtml(html)) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Color.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Color.kt index 79227f96cc1..d4045438c34 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Color.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Color.kt @@ -1,63 +1,107 @@ -@file:Suppress("MagicNumber") - package com.stripe.android.financialconnections.ui.theme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography -internal val Info100 = Color(0xffCFF5F6) -internal val Success100 = Color(0xffD7F7C2) - -internal val Neutral50 = Color(0xffF6F8FA) -internal val Neutral150 = Color(0xffE0E6EB) -internal val Neutral200 = Color(0xffC0C8D2) -internal val Neutral300 = Color(0xffA3ACBA) -internal val Neutral500 = Color(0xff6A7383) -internal val Neutral800 = Color(0xff30313D) +internal val Neutral0 = Color(0xffFFFFFF) +internal val Neutral50 = Color(0xffF5F6F8) +internal val Neutral900 = Color(0xff21252C) -internal val Attention500 = Color(0xffC84801) -internal val Attention400 = Color(0xffED6704) -internal val Attention100 = Color(0xffFCEDB9) -internal val Attention50 = Color(0xffFEF9DA) - -internal val Brand100 = Color(0xffF2EBFF) +internal val Brand50 = Color(0xffF7F5FD) internal val Brand400 = Color(0xff8D7FFA) -internal val Brand500 = Color(0xff625AFA) - -internal val Blue500 = Color(0xff0570DE) -internal val Blue400 = Color(0xFF0196ED) -internal val Red500 = Color(0xffDF1B41) +internal val Attention200 = Color(0xffFCAF4F) -internal val Green400 = Color(0xff3FA40D) -internal val Green500 = Color(0xff228403) +internal object LinkColors { + val Brand200 = Color(0xffA6FBDD) + val Brand600 = Color(0xff1AC59B) +} -/** - * Financial Connections custom Color Palette - */ @Immutable internal data class FinancialConnectionsColors( - // backgrounds - val backgroundSurface: Color, - val backgroundContainer: Color, - val backgroundBackdrop: Color, - // borders - val borderDefault: Color, - val borderFocus: Color, - val borderInvalid: Color, - // text - val textPrimary: Color, - val textSecondary: Color, + val textDefault: Color, + val textSubdued: Color, val textDisabled: Color, val textWhite: Color, val textBrand: Color, - val textInfo: Color, - val textSuccess: Color, - val textAttention: Color, val textCritical: Color, - // icons + val iconDefault: Color, + val iconSubdued: Color, + val iconWhite: Color, val iconBrand: Color, - val iconInfo: Color, - val iconSuccess: Color, - val iconAttention: Color + val iconCaution: Color, + val buttonPrimary: Color, + val buttonPrimaryHover: Color, + val buttonPrimaryPressed: Color, + val buttonSecondary: Color, + val buttonSecondaryHover: Color, + val buttonSecondaryPressed: Color, + val backgroundSurface: Color, + val background: Color, + val backgroundOffset: Color, + val backgroundBrand: Color, + val backgroundCaution: Color, + val border: Color, + val borderBrand: Color ) + +@Preview(group = "Components", name = "Colors") +@Composable +internal fun ColorsPreview() { + FinancialConnectionsPreview { + Column( + modifier = Modifier.background(Color.White) + ) { + ColorPreview("textDefault", colors.textDefault) + ColorPreview("textSubdued", colors.textSubdued) + ColorPreview("textDisabled", colors.textDisabled) + ColorPreview("textWhite", colors.textWhite) + ColorPreview("textBrand", colors.textBrand) + ColorPreview("textCritical", colors.textCritical) + ColorPreview("iconDefault", colors.iconDefault) + ColorPreview("iconSubdued", colors.iconSubdued) + ColorPreview("iconWhite", colors.iconWhite) + ColorPreview("iconBrand", colors.iconBrand) + ColorPreview("buttonPrimary", colors.buttonPrimary) + ColorPreview("buttonPrimaryHover", colors.buttonPrimaryHover) + ColorPreview("buttonPrimaryPressed", colors.buttonPrimaryPressed) + ColorPreview("buttonSecondary", colors.buttonSecondary) + ColorPreview("buttonSecondaryHover", colors.buttonSecondaryHover) + ColorPreview("buttonSecondaryPressed", colors.buttonSecondaryPressed) + ColorPreview("background", colors.background) + ColorPreview("backgroundBrand", colors.backgroundBrand) + ColorPreview("border", colors.border) + ColorPreview("borderBrand", colors.borderBrand) + } + } +} + +@Composable +private fun ColorPreview(colorText: String, color: Color) { + Row { + Box( + Modifier + .size(40.dp) + .background(color) + ) + Text( + text = colorText, + style = typography.bodyMedium, + modifier = Modifier.padding(10.dp) + ) + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Layout.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Layout.kt new file mode 100644 index 00000000000..db7660bc007 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Layout.kt @@ -0,0 +1,233 @@ +package com.stripe.android.financialconnections.ui.theme + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsButton +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsScaffold +import com.stripe.android.financialconnections.ui.components.FinancialConnectionsTopAppBar +import com.stripe.android.financialconnections.ui.components.elevation +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography + +/** + * A layout that contains a body, and an optional, bottom fixed footer. + * + * @param modifier the modifier to apply to the layout. + * @param body the content of the layout. + * @param footer the content of the footer. + * @param inModal whether the layout is being used in a modal or not. If true, the [body] won't expand to fill the + * available content. + * @param showFooterShadowWhenScrollable whether to show a shadow at the top of the footer when the body is scrollable. + * @param scrollState the [ScrollState] to use for the scrollable body. + */ +@Composable +internal fun Layout( + modifier: Modifier = Modifier, + body: @Composable ColumnScope.() -> Unit, + footer: (@Composable () -> Unit)? = null, + bodyPadding: PaddingValues = PaddingValues(horizontal = 24.dp), + inModal: Boolean = false, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + showFooterShadowWhenScrollable: Boolean = true, + scrollState: ScrollState = rememberScrollState(), +) { + LayoutScaffold( + canScrollForward = scrollState.canScrollForward, + inModal = inModal, + showFooterShadowWhenScrollable = showFooterShadowWhenScrollable, + modifier = modifier, + footer = footer, + ) { + Column( + modifier = Modifier + .verticalScroll(scrollState) + .animateContentSize(), + verticalArrangement = verticalArrangement, + ) { + // Nested columns to achieve proper content padding + Column(modifier = Modifier.padding(bodyPadding)) { + body() + } + } + } +} + +/** + * A layout that contains a body, and an optional, bottom fixed footer. + * + * @param modifier the modifier to apply to the layout. + * @param body the content of the layout. + * @param footer the content of the footer. + * @param inModal whether the layout is being used in a modal or not. If true, the [body] won't expand to fill the + * available content. + * @param showFooterShadowWhenScrollable whether to show a shadow at the top of the footer when the body is scrollable. + * @param lazyListState the [LazyListState] to use for the scrollable body. + */ +@Composable +internal fun LazyLayout( + modifier: Modifier = Modifier, + body: LazyListScope.() -> Unit, + footer: (@Composable () -> Unit)? = null, + bodyPadding: PaddingValues = PaddingValues(horizontal = 24.dp), + inModal: Boolean = false, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + showFooterShadowWhenScrollable: Boolean = true, + lazyListState: LazyListState = rememberLazyListState() +) { + LayoutScaffold( + canScrollForward = lazyListState.canScrollForward, + inModal = inModal, + showFooterShadowWhenScrollable = showFooterShadowWhenScrollable, + modifier = modifier, + footer = footer, + ) { + LazyColumn( + state = lazyListState, + verticalArrangement = verticalArrangement, + contentPadding = bodyPadding, + ) { + body() + } + } +} + +@Composable +private fun LayoutScaffold( + canScrollForward: Boolean, + inModal: Boolean, + showFooterShadowWhenScrollable: Boolean, + modifier: Modifier = Modifier, + footer: (@Composable () -> Unit)?, + body: @Composable () -> Unit, +) { + Column( + modifier + .also { if (inModal.not()) it.fillMaxSize() } + ) { + // Box to contain the layout body and an optional footer shadow drawn on top. + Box( + Modifier + .fillMaxWidth() + .weight(1f, fill = inModal.not()) + ) { + // Footer shadow (top aligned) + if (showFooterShadowWhenScrollable && canScrollForward) { + FooterTopShadow() + } + // Body content + body() + } + // Footer content (bottom aligned) + footer?.let { + Box( + modifier = Modifier.padding( + top = 16.dp, + bottom = 24.dp, + start = 24.dp, + end = 24.dp, + ), + content = { it() } + ) + } + } +} + +@Composable +private fun BoxScope.FooterTopShadow() { + val shadowSize = 4 + Box( + modifier = Modifier.Companion + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(shadowSize.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.LightGray.copy(alpha = 0.2f) + ), + 0.0f, + shadowSize.toFloat() + ) + ) + ) +} + +@Preview(showBackground = true) +@Composable +internal fun LayoutPreview() { + FinancialConnectionsTheme { + val state = rememberLazyListState() + FinancialConnectionsScaffold( + topBar = { + FinancialConnectionsTopAppBar( + hideStripeLogo = false, + elevation = state.elevation, + onCloseClick = {} + ) + }, + content = { + LazyLayout( + lazyListState = state, + body = { + item { + Text( + "Title", + style = typography.headingXLarge + ) + } + for (index in 1..50) { + item { + Text("Body item $index") + } + } + }, + footer = { + Column( + Modifier.fillMaxWidth() + ) { + FinancialConnectionsButton( + modifier = Modifier.fillMaxWidth(), + onClick = {} + ) { + Text("Button 1") + } + Spacer(modifier = Modifier.height(16.dp)) + FinancialConnectionsButton( + modifier = Modifier.fillMaxWidth(), + onClick = { } + ) { + Text("Button 1") + } + } + } + ) + } + ) + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/StripeThemeForConnections.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/StripeThemeForConnections.kt index fda3b1c0311..2c8338c7934 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/StripeThemeForConnections.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/StripeThemeForConnections.kt @@ -1,6 +1,7 @@ package com.stripe.android.financialconnections.ui.theme import androidx.compose.runtime.Composable +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.colors import com.stripe.android.uicore.StripeTheme import com.stripe.android.uicore.StripeThemeDefaults @@ -12,12 +13,16 @@ internal fun StripeThemeForConnections( val stripeDefaultColors = StripeThemeDefaults.colors(isDark = false) StripeTheme( colors = stripeDefaultColors.copy( + onComponent = colors.textDefault, + componentBorder = colors.border, + placeholderText = colors.textSubdued, materialColors = stripeDefaultColors.materialColors.copy( - primary = FinancialConnectionsTheme.colors.iconBrand + primary = colors.iconBrand, + error = colors.textCritical, ) ), shapes = StripeThemeDefaults.shapes.copy( - cornerRadius = 9f + cornerRadius = 12f ), typography = StripeThemeDefaults.typography ) { diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Theme.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Theme.kt index 8666b046549..ec497e1c811 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Theme.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Theme.kt @@ -22,136 +22,140 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogWindowProvider import androidx.core.view.WindowCompat +import androidx.navigation.compose.rememberNavController +import com.stripe.android.financialconnections.ui.LocalNavHostController -private val LightColorPalette = FinancialConnectionsColors( - backgroundSurface = Color.White, - backgroundContainer = Neutral50, - backgroundBackdrop = Neutral200.copy(alpha = .70f), - borderDefault = Neutral150, - borderFocus = Blue400.copy(alpha = .36f), - borderInvalid = Red500, - textPrimary = Neutral800, - textSecondary = Neutral500, - textDisabled = Neutral300, - textWhite = Color.White, - textBrand = Brand500, - textInfo = Blue500, - textSuccess = Green500, - textAttention = Attention500, - textCritical = Red500, - iconBrand = Brand400, - iconInfo = Blue400, - iconSuccess = Green400, - iconAttention = Attention400 +private val Colors = FinancialConnectionsColors( + textDefault = Color(0xFF353A44), + textSubdued = Color(0xFF596171), + textDisabled = Color(0xFF818DA0), + textWhite = Color(0xFFFFFFFF), + textBrand = Color(0xFF533AFD), + textCritical = Color(0xFFC0123C), + iconDefault = Color(0xFF474E5A), + iconSubdued = Color(0xFF6C7688), + iconWhite = Color(0xFFFFFFFF), + iconBrand = Color(0xFF675DFF), + iconCaution = Color(0xFFFF8F0E), + buttonPrimary = Color(0xFF675DFF), + buttonPrimaryHover = Color(0xFF857AFE), + buttonPrimaryPressed = Color(0xFF533AFD), + buttonSecondary = Color(0xFFF5F6F8), + buttonSecondaryHover = Color(0xFFF5F6F8), + buttonSecondaryPressed = Color(0xFFEBEEF1), + background = Color(0xFFF5F6F8), + backgroundSurface = Color(0xFFFFFFFF), + backgroundOffset = Color(0xFFF6F8FA), + backgroundBrand = Color(0xFFF5F6F8), + backgroundCaution = Color(0xFFFEF9DA), + border = Color(0xFFD8DEE4), + borderBrand = Color(0xFF675DFF), +) + +private val lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None ) private val Typography = FinancialConnectionsTypography( - subtitle = TextStyle( - fontSize = 24.sp, + headingXLarge = TextStyle( + fontSize = 28.sp, lineHeight = 32.sp, - fontWeight = FontWeight.W700 + letterSpacing = 0.38.sp, + fontWeight = FontWeight.W700, + lineHeightStyle = lineHeightStyle ).toCompat(), - subtitleEmphasized = TextStyle( - fontSize = 24.sp, + headingXLargeSubdued = TextStyle( + fontSize = 28.sp, lineHeight = 32.sp, - fontWeight = FontWeight.W700 + letterSpacing = 0.38.sp, + fontWeight = FontWeight.W400, + lineHeightStyle = lineHeightStyle ).toCompat(), - heading = TextStyle( - fontSize = 18.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.W700 - ).toCompat(), - subheading = TextStyle( - fontSize = 18.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.W600 + headingLarge = TextStyle( + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.30.sp, + fontWeight = FontWeight.W700, + lineHeightStyle = lineHeightStyle ).toCompat(), - kicker = TextStyle( - fontSize = 12.sp, - lineHeight = 20.sp, - fontWeight = FontWeight.W600 + headingMedium = TextStyle( + fontSize = 20.sp, + lineHeight = 28.sp, + letterSpacing = 0.30.sp, + fontWeight = FontWeight.W700, + lineHeightStyle = lineHeightStyle ).toCompat(), - body = TextStyle( + bodyMediumEmphasized = TextStyle( fontSize = 16.sp, lineHeight = 24.sp, - fontWeight = FontWeight.W400 + fontWeight = FontWeight.W600, + lineHeightStyle = lineHeightStyle ).toCompat(), - bodyEmphasized = TextStyle( + bodyMedium = TextStyle( fontSize = 16.sp, lineHeight = 24.sp, - fontWeight = FontWeight.W600 - ).toCompat(), - detail = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontWeight = FontWeight.W400 + fontWeight = FontWeight.W400, + lineHeightStyle = lineHeightStyle ).toCompat(), - detailEmphasized = TextStyle( + bodySmall = TextStyle( fontSize = 14.sp, lineHeight = 20.sp, - fontWeight = FontWeight.W600 - ).toCompat(), - caption = TextStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.W400 + fontWeight = FontWeight.W400, + lineHeightStyle = lineHeightStyle ).toCompat(), - captionEmphasized = TextStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.W600 - ).toCompat(), - captionTight = TextStyle( - fontSize = 12.sp, - lineHeight = 16.sp, - fontWeight = FontWeight.W400 + labelLargeEmphasized = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.W600, + lineHeightStyle = lineHeightStyle ).toCompat(), - captionTightEmphasized = TextStyle( - fontSize = 12.sp, - lineHeight = 16.sp, - fontWeight = FontWeight.W600 + labelLarge = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.W400, + lineHeightStyle = lineHeightStyle ).toCompat(), - bodyCode = TextStyle( + labelMediumEmphasized = TextStyle( fontSize = 14.sp, lineHeight = 20.sp, - fontWeight = FontWeight.W400 + fontWeight = FontWeight.W600, + lineHeightStyle = lineHeightStyle ).toCompat(), - bodyCodeEmphasized = TextStyle( + labelMedium = TextStyle( fontSize = 14.sp, lineHeight = 20.sp, - fontWeight = FontWeight.W700 + fontWeight = FontWeight.W400, + lineHeightStyle = lineHeightStyle ).toCompat(), - captionCode = TextStyle( + labelSmall = TextStyle( fontSize = 12.sp, lineHeight = 16.sp, - fontWeight = FontWeight.W400 - ).toCompat(), - captionCodeEmphasized = TextStyle( - fontSize = 12.sp, - lineHeight = 16.sp, - fontWeight = FontWeight.W700 + fontWeight = FontWeight.W400, + lineHeightStyle = lineHeightStyle ).toCompat(), ) private val TextSelectionColors = TextSelectionColors( - handleColor = LightColorPalette.textBrand, - backgroundColor = LightColorPalette.textBrand.copy(alpha = 0.4f) + handleColor = Colors.textBrand, + backgroundColor = Colors.textBrand.copy(alpha = 0.4f) ) @Immutable private object FinancialConnectionsRippleTheme : RippleTheme { @Composable override fun defaultColor() = RippleTheme.defaultRippleColor( - contentColor = LightColorPalette.textBrand, + contentColor = Colors.textBrand, lightTheme = MaterialTheme.colors.isLight ) @Composable override fun rippleAlpha() = RippleTheme.defaultRippleAlpha( - contentColor = LightColorPalette.textBrand, + contentColor = Colors.textBrand, lightTheme = MaterialTheme.colors.isLight ) } @@ -159,12 +163,13 @@ private object FinancialConnectionsRippleTheme : RippleTheme { @Composable internal fun FinancialConnectionsTheme(content: @Composable () -> Unit) { CompositionLocalProvider( - LocalFinancialConnectionsTypography provides Typography, - LocalFinancialConnectionsColors provides LightColorPalette + LocalNavHostController provides rememberNavController(), + LocalTypography provides Typography, + LocalColors provides Colors ) { val view = LocalView.current val window = findWindow() - val barColor = FinancialConnectionsTheme.colors.borderDefault + val barColor = FinancialConnectionsTheme.colors.border if (!view.isInEditMode) { SideEffect { window?.let { window -> @@ -203,22 +208,23 @@ private tailrec fun Context.findWindow(): Window? = else -> null } -private val LocalFinancialConnectionsTypography = +private val LocalTypography = staticCompositionLocalOf { - error("no FinancialConnectionsTypography provided") + error("no Typography provided") } -private val LocalFinancialConnectionsColors = staticCompositionLocalOf { - error("No FinancialConnectionsColors provided") -} +private val LocalColors = + staticCompositionLocalOf { + error("no Colors provided") + } internal object FinancialConnectionsTheme { - val colors: FinancialConnectionsColors + val typography @Composable - get() = LocalFinancialConnectionsColors.current - val typography: FinancialConnectionsTypography + get() = LocalTypography.current + val colors @Composable - get() = LocalFinancialConnectionsTypography.current + get() = LocalColors.current } private fun TextStyle.toCompat(useDefaultLineHeight: Boolean = false): TextStyle { diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Type.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Type.kt index 77a960b3b4f..8538bd1155d 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Type.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/ui/theme/Type.kt @@ -1,93 +1,90 @@ -@file:Suppress("MatchingDeclarationName", "ktlint:filename") - package com.stripe.android.financialconnections.ui.theme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.material.Divider import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import com.stripe.android.financialconnections.ui.FinancialConnectionsPreview +import com.stripe.android.financialconnections.ui.theme.FinancialConnectionsTheme.typography @Immutable internal data class FinancialConnectionsTypography( - val subtitle: TextStyle, - val subtitleEmphasized: TextStyle, - val heading: TextStyle, - val subheading: TextStyle, - val kicker: TextStyle, - val body: TextStyle, - val bodyEmphasized: TextStyle, - val detail: TextStyle, - val detailEmphasized: TextStyle, - val caption: TextStyle, - val captionEmphasized: TextStyle, - val captionTight: TextStyle, - val captionTightEmphasized: TextStyle, - val bodyCode: TextStyle, - val bodyCodeEmphasized: TextStyle, - val captionCode: TextStyle, - val captionCodeEmphasized: TextStyle, - + val headingXLarge: TextStyle, + val headingXLargeSubdued: TextStyle, + val headingLarge: TextStyle, + val headingMedium: TextStyle, + val bodyMediumEmphasized: TextStyle, + val bodyMedium: TextStyle, + val bodySmall: TextStyle, + val labelLargeEmphasized: TextStyle, + val labelLarge: TextStyle, + val labelMediumEmphasized: TextStyle, + val labelMedium: TextStyle, + val labelSmall: TextStyle ) @Preview(group = "Components", name = "Type") @Composable internal fun TypePreview() { FinancialConnectionsPreview { - Column { - Text( - text = "subtitle", - style = FinancialConnectionsTheme.typography.subtitle - ) + Column( + modifier = Modifier.background(Color.White) + ) { Text( - text = "subtitleEmphasized", - style = FinancialConnectionsTheme.typography.subtitleEmphasized + text = "Heading XLarge", + style = typography.headingXLarge ) Text( - text = "heading", - style = FinancialConnectionsTheme.typography.heading + text = "Heading XLarge Subdued", + style = typography.headingXLargeSubdued ) Text( - text = "subheading", - style = FinancialConnectionsTheme.typography.subheading + text = "Heading Large", + style = typography.headingLarge ) Text( - text = "KICKER", - style = FinancialConnectionsTheme.typography.kicker + text = "Heading Medium", + style = typography.headingMedium ) + Divider() Text( - text = "body", - style = FinancialConnectionsTheme.typography.body + text = "Body Medium Emphasized", + style = typography.bodyMediumEmphasized ) Text( - text = "bodyEmphasized", - style = FinancialConnectionsTheme.typography.bodyEmphasized + text = "Body Medium", + style = typography.bodyMedium ) Text( - text = "detail", - style = FinancialConnectionsTheme.typography.detail + text = "Body Small", + style = typography.bodySmall ) + Divider() Text( - text = "detailEmphasized", - style = FinancialConnectionsTheme.typography.detailEmphasized + text = "Label Large Emphasized", + style = typography.labelLargeEmphasized ) Text( - text = "caption", - style = FinancialConnectionsTheme.typography.caption + text = "Label Large", + style = typography.labelLarge ) Text( - text = "captionEmphasized", - style = FinancialConnectionsTheme.typography.captionEmphasized + text = "Label Medium Emphasized", + style = typography.labelMediumEmphasized ) Text( - text = "captionTight", - style = FinancialConnectionsTheme.typography.captionTight + text = "Label Medium", + style = typography.labelMedium ) Text( - text = "captionTightEmphasized", - style = FinancialConnectionsTheme.typography.captionTightEmphasized + text = "Label Small", + style = typography.labelSmall ) } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/KeyboardController.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/KeyboardController.kt new file mode 100644 index 00000000000..f7a50e921e8 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/KeyboardController.kt @@ -0,0 +1,68 @@ +package com.stripe.android.financialconnections.utils + +import android.view.ViewTreeObserver +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.platform.LocalView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import kotlinx.coroutines.flow.first + +internal class KeyboardController( + private val dismissKeyboard: () -> Unit, + private val isKeyboardVisible: State , +) { + + suspend fun dismiss() { + if (isKeyboardVisible.value) { + dismissKeyboard() + awaitKeyboardDismissed() + } + } + + private suspend fun awaitKeyboardDismissed() { + snapshotFlow { isKeyboardVisible.value }.first { !it } + } +} + +@Composable +internal fun rememberKeyboardController(): KeyboardController { + val textInputService = LocalTextInputService.current + val keyboardState = isKeyboardVisibleAsState() + + return KeyboardController( + dismissKeyboard = { + // We're using this method because LocalSoftwareKeyboardController is + // still experimental in Compose 1.5. We should switch over once we update + // to Compose 1.6, in which the experimental state has been removed. + textInputService?.hideSoftwareKeyboard() + }, + isKeyboardVisible = keyboardState, + ) +} + +@Composable +private fun isKeyboardVisibleAsState(): State { + val view = LocalView.current + val state = remember { mutableStateOf(false) } + + DisposableEffect(view) { + val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener { + val insets = ViewCompat.getRootWindowInsets(view) + val isKeyboardOpen = insets?.isVisible(WindowInsetsCompat.Type.ime()) ?: true + state.value = isKeyboardOpen + } + + view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) + onDispose { + view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) + } + } + + return state +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/UriUtils.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/UriUtils.kt index a67775657f3..26bc05610c9 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/UriUtils.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/UriUtils.kt @@ -11,7 +11,7 @@ internal class UriUtils @Inject constructor( private val logger: Logger, private val tracker: FinancialConnectionsAnalyticsTracker, ) { - suspend fun compareSchemeAuthorityAndPath( + fun compareSchemeAuthorityAndPath( uriString1: String, uriString2: String ): Boolean { @@ -28,7 +28,7 @@ internal class UriUtils @Inject constructor( * * stripe-auth://link-accounts/authentication_return?code=failure */ - suspend fun getQueryParameter(uri: String, key: String): String? = kotlin.runCatching { + fun getQueryParameter(uri: String, key: String): String? = kotlin.runCatching { uri.toUriOrNull()?.getQueryParameter(key) }.onFailure { error -> tracker.logError( @@ -66,7 +66,7 @@ internal class UriUtils @Inject constructor( ) }.getOrNull() - private suspend fun String.toUriOrNull(): Uri? = kotlin.runCatching { + private fun String.toUriOrNull(): Uri? = kotlin.runCatching { return Uri.parse(this) }.onFailure { error -> tracker.logError( diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/TestFinancialConnectionsAnalyticsTracker.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/TestFinancialConnectionsAnalyticsTracker.kt index 671b17f743e..d5bd11929c2 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/TestFinancialConnectionsAnalyticsTracker.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/TestFinancialConnectionsAnalyticsTracker.kt @@ -8,9 +8,8 @@ internal class TestFinancialConnectionsAnalyticsTracker : FinancialConnectionsAn val sentEvents = mutableListOf () - override suspend fun track(event: FinancialConnectionsAnalyticsEvent): Result { + override fun track(event: FinancialConnectionsAnalyticsEvent) { sentEvents += event - return Result.success(Unit) } /** diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/exception/FinancialConnectionsErrorHandlerTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/exception/FinancialConnectionsErrorHandlerTest.kt new file mode 100644 index 00000000000..e595d4e0f30 --- /dev/null +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/exception/FinancialConnectionsErrorHandlerTest.kt @@ -0,0 +1,104 @@ +package com.stripe.android.financialconnections.exception + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.testing.TestLifecycleOwner +import com.google.common.truth.Truth.assertThat +import com.stripe.android.core.exception.APIConnectionException +import com.stripe.android.core.exception.StripeException +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.lang.Thread.UncaughtExceptionHandler + +@OptIn(ExperimentalCoroutinesApi::class) +internal class FinancialConnectionsErrorHandlerTest { + + @Before + fun before() { + Dispatchers.setMain(StandardTestDispatcher()) + } + + @After + fun after() { + Dispatchers.resetMain() + } + + @Test + fun `Correctly consumes exceptions when merchant has uncaught exception handler`() = runTest { + val merchantHandler = RecordingUncaughtExceptionHandler() + val financialConnectionsHandler = RecordingFinancialConnectionsErrorHandler() + + Thread.setDefaultUncaughtExceptionHandler(merchantHandler) + + val lifecycleOwner = TestLifecycleOwner() + financialConnectionsHandler.handler.setup(lifecycleOwner) + + mockUncaughtException() + + assertThat(merchantHandler.encounteredErrors).hasSize(1) + assertThat(financialConnectionsHandler.encounteredErrors).hasSize(1) + + lifecycleOwner.setCurrentState(Lifecycle.State.DESTROYED) + + mockUncaughtException() + + assertThat(merchantHandler.encounteredErrors).hasSize(2) + assertThat(financialConnectionsHandler.encounteredErrors).hasSize(1) + } + + @Test + fun `Correctly consumes exceptions when merchant does not have uncaught exception handler`() = runTest { + val financialConnectionsHandler = RecordingFinancialConnectionsErrorHandler() + Thread.setDefaultUncaughtExceptionHandler(null) + + val lifecycleOwner = TestLifecycleOwner() + financialConnectionsHandler.handler.setup(lifecycleOwner) + + mockUncaughtException() + assertThat(financialConnectionsHandler.encounteredErrors).hasSize(1) + + lifecycleOwner.setCurrentState(Lifecycle.State.DESTROYED) + + // The FinancialConnectionsErrorHandler no longer receives uncaught exceptions + mockUncaughtException() + assertThat(financialConnectionsHandler.encounteredErrors).hasSize(1) + } + + private fun mockUncaughtException() { + Thread.getDefaultUncaughtExceptionHandler()?.uncaughtException( + Thread.currentThread(), + StripeException.create(APIConnectionException()), + ) + } +} + +private class RecordingUncaughtExceptionHandler : UncaughtExceptionHandler { + + private val _encounteredErrors = mutableListOf () + + val encounteredErrors: List + get() = _encounteredErrors + + override fun uncaughtException(thread: Thread, error: Throwable) { + _encounteredErrors += error + } +} + +private class RecordingFinancialConnectionsErrorHandler { + + private val trackedEvents = mutableListOf () + private val tracker = FinancialConnectionsAnalyticsTracker(trackedEvents::add) + + val handler = FinancialConnectionsErrorHandler(tracker) + + val encounteredErrors: List + get() = trackedEvents +} diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt index 257176962d1..1577b4cd713 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt @@ -46,6 +46,7 @@ internal class AccountPickerViewModelTest { getOrFetchSync = getSync, navigationManager = navigationManager, logger = Logger.noop(), + handleClickableUrl = mock(), pollAuthorizationSessionAccounts = pollAuthorizationSessionAccounts ) @@ -146,6 +147,48 @@ internal class AccountPickerViewModelTest { withState(viewModel) { state -> assertThat(state.selectedIds).isEqualTo(setOf("selectable")) } + + eventTracker.assertContainsEvent( + "linked_accounts.account_picker.accounts_auto_selected", + mapOf( + "account_ids" to "selectable", + "is_single_account" to "true", + ) + ) + } + + @Test + fun `init - if not singleAccount, pre-selects first available account`() = runTest { + givenManifestReturns( + sessionManifest().copy( + singleAccount = false, + activeAuthSession = authorizationSession() + ) + ) + + givenPollAccountsReturns( + partnerAccountList().copy( + data = listOf( + partnerAccount().copy(id = "unelectable", _allowSelection = false), + partnerAccount().copy(id = "selectable_1"), + partnerAccount().copy(id = "selectable_2") + ) + ) + ) + + val viewModel = buildViewModel(AccountPickerState()) + + withState(viewModel) { state -> + assertThat(state.selectedIds).isEqualTo(setOf("selectable_1", "selectable_2")) + } + + eventTracker.assertContainsEvent( + "linked_accounts.account_picker.accounts_auto_selected", + mapOf( + "account_ids" to "selectable_1 selectable_2", + "is_single_account" to "false", + ) + ) } private suspend fun givenManifestReturns(manifest: FinancialConnectionsSessionManifest) { diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerViewModelTest.kt index 1313da95c26..475f85539ee 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/institutionpicker/InstitutionPickerViewModelTest.kt @@ -8,18 +8,25 @@ import com.stripe.android.financialconnections.ApiKeyFixtures import com.stripe.android.financialconnections.FinancialConnectionsSheet import com.stripe.android.financialconnections.TestFinancialConnectionsAnalyticsTracker import com.stripe.android.financialconnections.domain.FeaturedInstitutions -import com.stripe.android.financialconnections.domain.GetManifest +import com.stripe.android.financialconnections.domain.GetOrFetchSync +import com.stripe.android.financialconnections.domain.PostAuthorizationSession import com.stripe.android.financialconnections.domain.SearchInstitutions import com.stripe.android.financialconnections.domain.UpdateLocalManifest +import com.stripe.android.financialconnections.exception.InstitutionPlannedDowntimeError +import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.InstitutionResponse -import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.navigation.Destination +import com.stripe.android.financialconnections.utils.TestHandleError +import com.stripe.android.financialconnections.utils.TestNavigationManager import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @@ -35,9 +42,11 @@ internal class InstitutionPickerViewModelTest { private val searchInstitutions = mock () private val featuredInstitutions = mock () - private val getManifest = mock () + private val sync = mock () + private val handleError = TestHandleError() private val updateLocalManifest = mock () - private val navigationManager = mock () + private val navigationManager = TestNavigationManager() + private val postAuthorizationSession = mock () private val eventTracker = TestFinancialConnectionsAnalyticsTracker() private val defaultConfiguration = FinancialConnectionsSheet.Configuration( ApiKeyFixtures.DEFAULT_FINANCIAL_CONNECTIONS_SESSION_SECRET, @@ -51,11 +60,13 @@ internal class InstitutionPickerViewModelTest { configuration = defaultConfiguration, searchInstitutions = searchInstitutions, featuredInstitutions = featuredInstitutions, - getManifest = getManifest, + getOrFetchSync = sync, navigationManager = navigationManager, updateLocalManifest = updateLocalManifest, logger = Logger.noop(), eventTracker = eventTracker, + postAuthorizationSession = postAuthorizationSession, + handleError = handleError, initialState = state ) } @@ -82,11 +93,45 @@ internal class InstitutionPickerViewModelTest { val viewModel = buildViewModel(InstitutionPickerState()) withState(viewModel) { state -> - assertEquals(state.payload()!!.featuredInstitutions, institutionResponse.data) + assertEquals(state.payload()!!.featuredInstitutions, institutionResponse) assertIs (state.searchInstitutions) } } + @Test + fun `init - fails to fetch featured institutions succeeds with empty list`() = runTest { + val error = RuntimeException("error") + whenever(featuredInstitutions(defaultConfiguration.financialConnectionsSessionClientSecret)) + .thenThrow(error) + + givenManifestReturns(ApiKeyFixtures.sessionManifest()) + + val viewModel = buildViewModel(InstitutionPickerState()) + + withState(viewModel) { state -> + // payload with empty list + assertTrue(state.payload()!!.featuredInstitutions.data.isEmpty()) + } + } + + @Test + fun `init - fail to fetch payload launches error screen`() = runTest { + val error = RuntimeException("error") + whenever(sync()).thenThrow(error) + + val viewModel = buildViewModel(InstitutionPickerState()) + + withState(viewModel) { state -> + assertTrue(state.payload() == null) + handleError.assertError( + error = error, + extraMessage = "Error fetching initial payload", + pane = Pane.INSTITUTION_PICKER, + displayErrorScreen = true + ) + } + } + @Test fun `onQueryChanged - institutions are searched and event sent`() = runTest { val query = "query" @@ -126,8 +171,8 @@ internal class InstitutionPickerViewModelTest { advanceUntilIdle() withState(viewModel) { state -> - assertEquals(state.payload()!!.featuredInstitutions, featuredResults.data) - assertEquals(state.searchInstitutions()!!.data, searchResults.data) + assertEquals(state.payload()!!.featuredInstitutions, featuredResults) + assertEquals(state.searchInstitutions()!!, searchResults) eventTracker.assertContainsEvent( expectedEventName = "linked_accounts.search.succeeded", expectedParams = mapOf( @@ -155,8 +200,79 @@ internal class InstitutionPickerViewModelTest { } } + @Test + fun `onInstitutionSelected - OAuth institution navigates to partner Auth in modal mode`() = runTest { + val institution = ApiKeyFixtures.institution() + + givenManifestReturns(ApiKeyFixtures.sessionManifest()) + givenCreateSessionForInstitutionReturns(ApiKeyFixtures.authorizationSession().copy(_isOAuth = true)) + + val viewModel = buildViewModel(InstitutionPickerState()) + + viewModel.onInstitutionSelected(institution, fromFeatured = true) + + navigationManager.assertNavigatedTo( + destination = Destination.PartnerAuthDrawer, + pane = Pane.INSTITUTION_PICKER, + ) + } + + @Test + fun `onInstitutionSelected - non-OAuth institution navigates to partner Auth in full-screen mode`() = runTest { + val institution = ApiKeyFixtures.institution() + + givenManifestReturns(ApiKeyFixtures.sessionManifest()) + givenCreateSessionForInstitutionReturns(ApiKeyFixtures.authorizationSession().copy(_isOAuth = false)) + + val viewModel = buildViewModel(InstitutionPickerState()) + + viewModel.onInstitutionSelected(institution, fromFeatured = true) + + navigationManager.assertNavigatedTo( + destination = Destination.PartnerAuth, + pane = Pane.INSTITUTION_PICKER, + ) + } + + @Test + fun `onInstitutionSelected - Failed to create AuthSession navigates to error screen`() = runTest { + val institution = ApiKeyFixtures.institution() + + givenManifestReturns(ApiKeyFixtures.sessionManifest()) + val error = InstitutionPlannedDowntimeError( + institution, + showManualEntry = true, + isToday = true, + backUpAt = 10000L, + stripeException = mock() + ) + givenCreateSessionForInstitutionThrows(error) + + val viewModel = buildViewModel(InstitutionPickerState()) + + viewModel.onInstitutionSelected(institution, fromFeatured = true) + + handleError.assertError( + error = error, + extraMessage = "Error selecting or creating session for institution", + pane = Pane.INSTITUTION_PICKER, + displayErrorScreen = true + ) + } + + private suspend fun givenCreateSessionForInstitutionThrows(throwable: Throwable) { + whenever(postAuthorizationSession(any(), any())).then { throw throwable } + } + + private suspend fun InstitutionPickerViewModelTest.givenCreateSessionForInstitutionReturns( + financialConnectionsAuthorizationSession: FinancialConnectionsAuthorizationSession + ) { + whenever(postAuthorizationSession(any(), any())) + .thenReturn(financialConnectionsAuthorizationSession) + } + private suspend fun givenManifestReturns(manifest: FinancialConnectionsSessionManifest) { - whenever(getManifest()).thenReturn(manifest) + whenever(sync()).thenReturn(ApiKeyFixtures.syncResponse(manifest)) } private suspend fun givenSearchInstitutionsReturns( diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModelTest.kt index fc7def9ab08..cb28ed3c8a1 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModelTest.kt @@ -6,11 +6,11 @@ import com.stripe.android.core.Logger import com.stripe.android.financialconnections.ApiKeyFixtures.consumerSession import com.stripe.android.financialconnections.ApiKeyFixtures.institution import com.stripe.android.financialconnections.ApiKeyFixtures.partnerAccount -import com.stripe.android.financialconnections.ApiKeyFixtures.sessionManifest +import com.stripe.android.financialconnections.ApiKeyFixtures.syncResponse import com.stripe.android.financialconnections.TestFinancialConnectionsAnalyticsTracker import com.stripe.android.financialconnections.domain.FetchNetworkedAccounts import com.stripe.android.financialconnections.domain.GetCachedConsumerSession -import com.stripe.android.financialconnections.domain.GetManifest +import com.stripe.android.financialconnections.domain.GetOrFetchSync import com.stripe.android.financialconnections.domain.SelectNetworkedAccount import com.stripe.android.financialconnections.domain.UpdateCachedAccounts import com.stripe.android.financialconnections.domain.UpdateLocalManifest @@ -44,7 +44,7 @@ class LinkAccountPickerViewModelTest { @get:Rule val mavericksTestRule = MavericksTestRule() - private val getManifest = mock () + private val getSync = mock () private val navigationManager = TestNavigationManager() private val getCachedConsumerSession = mock () private val fetchNetworkedAccounts = mock () @@ -57,7 +57,7 @@ class LinkAccountPickerViewModelTest { state: LinkAccountPickerState ) = LinkAccountPickerViewModel( navigationManager = navigationManager, - getManifest = getManifest, + getSync = getSync, logger = Logger.noop(), eventTracker = eventTracker, getCachedConsumerSession = getCachedConsumerSession, @@ -66,12 +66,13 @@ class LinkAccountPickerViewModelTest { updateLocalManifest = updateLocalManifest, updateCachedAccounts = updateCachedAccounts, initialState = state, + handleClickableUrl = mock(), coreAuthorizationPendingNetworkingRepair = mock() ) @Test fun `init - Fetches existing accounts and zips them by id`() = runTest { - whenever(getManifest()).thenReturn(sessionManifest()) + whenever(getSync()).thenReturn(syncResponse()) whenever(getCachedConsumerSession()).thenReturn(consumerSession()) whenever(fetchNetworkedAccounts(any())).thenReturn( NetworkedAccountsList( @@ -110,7 +111,7 @@ class LinkAccountPickerViewModelTest { val response = twoAccounts().copy( nextPaneOnAddAccount = Pane.INSTITUTION_PICKER ) - whenever(getManifest()).thenReturn(sessionManifest()) + whenever(getSync()).thenReturn(syncResponse()) whenever(getCachedConsumerSession()).thenReturn(consumerSession()) whenever(fetchNetworkedAccounts(any())).thenReturn(response) @@ -140,7 +141,7 @@ class LinkAccountPickerViewModelTest { ) ) val selectedAccount = accounts.data.first() - whenever(getManifest()).thenReturn(sessionManifest()) + whenever(getSync()).thenReturn(syncResponse()) whenever(getCachedConsumerSession()).thenReturn(consumerSession()) whenever(fetchNetworkedAccounts(any())).thenReturn(accounts) whenever( @@ -183,7 +184,7 @@ class LinkAccountPickerViewModelTest { ) ) val selectedAccount = accounts.data.first() - whenever(getManifest()).thenReturn(sessionManifest()) + whenever(getSync()).thenReturn(syncResponse()) whenever(getCachedConsumerSession()).thenReturn(consumerSession()) whenever(fetchNetworkedAccounts(any())).thenReturn(accounts) whenever( diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryViewModelTest.kt index 59f01ab9e6a..934fee07019 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/manualentry/ManualEntryViewModelTest.kt @@ -67,7 +67,8 @@ class ManualEntryViewModelTest { Success( Payload( customManualEntry = true, - verifyWithMicrodeposits = sync.manifest.manualEntryUsesMicrodeposits + verifyWithMicrodeposits = sync.manifest.manualEntryUsesMicrodeposits, + testMode = false ) ) ) diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupViewModelTest.kt index de769be954d..cfa88956807 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinkloginwarmup/NetworkingLinkLoginWarmupViewModelTest.kt @@ -1,14 +1,15 @@ package com.stripe.android.financialconnections.features.networkinglinkloginwarmup import com.airbnb.mvrx.test.MavericksTestRule -import com.stripe.android.core.Logger import com.stripe.android.financialconnections.ApiKeyFixtures import com.stripe.android.financialconnections.TestFinancialConnectionsAnalyticsTracker import com.stripe.android.financialconnections.domain.DisableNetworking import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.navigation.Destination +import com.stripe.android.financialconnections.navigation.PopUpToBehavior import com.stripe.android.financialconnections.navigation.destination +import com.stripe.android.financialconnections.utils.TestHandleError import com.stripe.android.financialconnections.utils.TestNavigationManager import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -24,6 +25,7 @@ class NetworkingLinkLoginWarmupViewModelTest { private val getManifest = mock () private val navigationManager = TestNavigationManager() + private val handleError = TestHandleError() private val disableNetworking = mock () private val eventTracker = TestFinancialConnectionsAnalyticsTracker() @@ -32,14 +34,29 @@ class NetworkingLinkLoginWarmupViewModelTest { ) = NetworkingLinkLoginWarmupViewModel( navigationManager = navigationManager, getManifest = getManifest, - logger = Logger.noop(), + handleError = handleError, disableNetworking = disableNetworking, eventTracker = eventTracker, initialState = state ) @Test - fun `onContinueClick - navigates to verification pane`() { + fun `init - payload error navigates to error screen`() = runTest { + val error = RuntimeException("Failed to fetch manifest") + whenever(getManifest()).thenAnswer { throw error } + + buildViewModel(NetworkingLinkLoginWarmupState()) + + handleError.assertError( + extraMessage = "Error fetching payload", + pane = Pane.NETWORKING_LINK_LOGIN_WARMUP, + error = error, + displayErrorScreen = true + ) + } + + @Test + fun `onContinueClick - navigates to verification pane`() = runTest { val viewModel = buildViewModel(NetworkingLinkLoginWarmupState()) viewModel.onContinueClick() @@ -49,6 +66,26 @@ class NetworkingLinkLoginWarmupViewModelTest { ) } + @Test + fun `onSkipClicked - navigates to institution picker and clears back stack`() = runTest { + val referrer = Pane.CONSENT + val viewModel = buildViewModel(NetworkingLinkLoginWarmupState(referrer)) + + whenever(disableNetworking()).thenReturn( + ApiKeyFixtures.sessionManifest().copy(nextPane = Pane.INSTITUTION_PICKER) + ) + + viewModel.onSkipClicked() + navigationManager.assertNavigatedTo( + destination = Destination.InstitutionPicker, + popUpTo = PopUpToBehavior.Route( + route = referrer.destination.fullRoute, + inclusive = true, + ), + pane = Pane.NETWORKING_LINK_LOGIN_WARMUP, + ) + } + @Test fun `onClickableTextClick - skip_login disables networking and navigates`() = runTest { val viewModel = buildViewModel(NetworkingLinkLoginWarmupState()) @@ -58,11 +95,12 @@ class NetworkingLinkLoginWarmupViewModelTest { ApiKeyFixtures.sessionManifest().copy(nextPane = expectedNextPane) ) - viewModel.onClickableTextClick("skip_login") + viewModel.onSkipClicked() verify(disableNetworking).invoke() navigationManager.assertNavigatedTo( destination = expectedNextPane.destination, + popUpTo = PopUpToBehavior.Current(inclusive = true), pane = Pane.NETWORKING_LINK_LOGIN_WARMUP ) } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModelTest.kt index 5c4fc4403e8..2210d4f903e 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModelTest.kt @@ -1,5 +1,6 @@ package com.stripe.android.financialconnections.features.networkinglinksignup +import app.cash.turbine.test import com.airbnb.mvrx.test.MavericksTestRule import com.google.common.truth.Truth.assertThat import com.stripe.android.core.Logger @@ -11,11 +12,14 @@ import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.domain.LookupAccount import com.stripe.android.financialconnections.domain.SaveAccountToLink import com.stripe.android.financialconnections.domain.SynchronizeFinancialConnectionsSession +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane.NETWORKING_LINK_SIGNUP_PANE import com.stripe.android.financialconnections.model.NetworkingLinkSignupBody import com.stripe.android.financialconnections.model.NetworkingLinkSignupPane import com.stripe.android.financialconnections.model.TextUpdate +import com.stripe.android.financialconnections.navigation.Destination.NetworkingSaveToLinkVerification +import com.stripe.android.financialconnections.navigation.NavigationIntent +import com.stripe.android.financialconnections.navigation.NavigationManagerImpl import com.stripe.android.financialconnections.repository.SaveToLinkWithStripeSucceededRepository -import com.stripe.android.financialconnections.utils.TestNavigationManager import com.stripe.android.financialconnections.utils.UriUtils import com.stripe.android.model.ConsumerSessionLookup import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -26,6 +30,8 @@ import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) @@ -39,7 +45,7 @@ class NetworkingLinkSignupViewModelTest { private val getManifest = mock () private val eventTracker = TestFinancialConnectionsAnalyticsTracker() private val getAuthorizationSessionAccounts = mock () - private val navigationManager = TestNavigationManager() + private val navigationManager = NavigationManagerImpl() private val lookupAccount = mock () private val saveAccountToLink = mock () private val sync = mock () @@ -85,6 +91,83 @@ class NetworkingLinkSignupViewModelTest { assertThat(payload.emailController.fieldValue.first()).isEqualTo("test@test.com") } + @Test + fun `Redirects to verification screen if entering returning user email`() = runTest { + val manifest = ApiKeyFixtures.sessionManifest() + + whenever(sync()).thenReturn( + syncResponse().copy( + text = TextUpdate( + consent = null, + networkingLinkSignupPane = networkingLinkSignupPane() + ) + ) + ) + whenever(getManifest()).thenReturn(manifest) + whenever(lookupAccount(any())).thenReturn(ConsumerSessionLookup(exists = true)) + + val viewModel = buildViewModel(NetworkingLinkSignupState()) + + navigationManager.navigationFlow.test { + val state = viewModel.awaitState() + val payload = requireNotNull(state.payload()) + payload.emailController.onValueChange("email@email.com") + + assertThat(awaitItem()).isEqualTo( + NavigationIntent.NavigateTo( + route = NetworkingSaveToLinkVerification(referrer = NETWORKING_LINK_SIGNUP_PANE), + popUpTo = null, + isSingleTop = true, + ) + ) + } + } + + @Test + fun `Enables Save To Link button if we encounter a returning user`() = runTest { + val manifest = ApiKeyFixtures.sessionManifest() + + whenever(sync()).thenReturn( + syncResponse().copy( + text = TextUpdate( + consent = null, + networkingLinkSignupPane = networkingLinkSignupPane() + ) + ) + ) + whenever(getManifest()).thenReturn(manifest) + whenever(lookupAccount(any())).thenReturn(ConsumerSessionLookup(exists = true)) + + val viewModel = buildViewModel(NetworkingLinkSignupState()) + + navigationManager.navigationFlow.test { + val state = viewModel.awaitState() + val payload = requireNotNull(state.payload()) + payload.emailController.onValueChange("email@email.com") + + assertThat(awaitItem()).isEqualTo( + NavigationIntent.NavigateTo( + route = NetworkingSaveToLinkVerification(referrer = NETWORKING_LINK_SIGNUP_PANE), + popUpTo = null, + isSingleTop = true, + ) + ) + + // Simulate the user pressing Save To Link after returning from the OTP screen + viewModel.onSaveAccount() + + verify(saveAccountToLink, never()).new(any(), any(), any(), any()) + + assertThat(awaitItem()).isEqualTo( + NavigationIntent.NavigateTo( + route = NetworkingSaveToLinkVerification(referrer = NETWORKING_LINK_SIGNUP_PANE), + popUpTo = null, + isSingleTop = true, + ) + ) + } + } + private fun networkingLinkSignupPane() = NetworkingLinkSignupPane( aboveCta = "Above CTA", body = NetworkingLinkSignupBody(emptyList()), diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt index 46e69d3263e..afe87cbf1d5 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt @@ -12,7 +12,6 @@ import com.stripe.android.financialconnections.ApiKeyFixtures.sessionManifest import com.stripe.android.financialconnections.ApiKeyFixtures.syncResponse import com.stripe.android.financialconnections.analytics.AuthSessionEvent import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker -import com.stripe.android.financialconnections.analytics.logError import com.stripe.android.financialconnections.domain.CancelAuthorizationSession import com.stripe.android.financialconnections.domain.CompleteAuthorizationSession import com.stripe.android.financialconnections.domain.GetOrFetchSync @@ -24,6 +23,7 @@ import com.stripe.android.financialconnections.exception.InstitutionUnplannedDow import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.MixedOAuthParams import com.stripe.android.financialconnections.presentation.WebAuthFlowState +import com.stripe.android.financialconnections.utils.TestHandleError import com.stripe.android.financialconnections.utils.TestNavigationManager import com.stripe.android.financialconnections.utils.UriUtils import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -34,9 +34,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.times import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @@ -58,9 +56,10 @@ internal class PartnerAuthViewModelTest { private val navigationManager = TestNavigationManager() private val createAuthorizationSession = mock () private val logger = mock () + private val handleError = TestHandleError() @Test - fun `init - when creating auth session returns unplanned downtime, error is logged`() = + fun `init - when creating auth session returns unplanned downtime, error is logged and displayed`() = runTest { val unplannedDowntimeError = InstitutionUnplannedDowntimeError( institution = institution(), @@ -77,14 +76,12 @@ internal class PartnerAuthViewModelTest { val viewModel = createViewModel() withState(viewModel) { - verifyBlocking(eventTracker) { - logError( - extraMessage = "Error fetching payload / posting AuthSession", - error = unplannedDowntimeError, - logger = logger, - pane = Pane.PARTNER_AUTH - ) - } + handleError.assertError( + extraMessage = "Error fetching payload / posting AuthSession", + error = unplannedDowntimeError, + pane = Pane.PARTNER_AUTH, + displayErrorScreen = true + ) } } @@ -200,6 +197,7 @@ internal class PartnerAuthViewModelTest { runTest { val activeAuthSession = authorizationSession().copy(url = null) val activeInstitution = institution() + // An auth session was created on the previous pane, before launching Partner Auth. val manifest = sessionManifest().copy( activeAuthSession = activeAuthSession.copy(_isOAuth = true), activeInstitution = activeInstitution, @@ -222,8 +220,8 @@ internal class PartnerAuthViewModelTest { // stays in partner auth pane assertThat(navigationManager.emittedIntents).isEmpty() - // creates two sessions (initial and retry) - verify(createAuthorizationSession, times(2)).invoke( + // creates an additional session (cancel triggers a retry) + verify(createAuthorizationSession).invoke( eq(activeInstitution), eq(syncResponse) ) @@ -240,6 +238,7 @@ internal class PartnerAuthViewModelTest { runTest { val activeAuthSession = authorizationSession().copy(url = null) val activeInstitution = institution() + // An auth session was created on the previous pane, before launching Partner Auth. val manifest = sessionManifest().copy( activeAuthSession = activeAuthSession.copy(_isOAuth = true), activeInstitution = activeInstitution @@ -259,8 +258,8 @@ internal class PartnerAuthViewModelTest { // stays in partner auth pane assertThat(navigationManager.emittedIntents).isEmpty() - // creates two sessions (initial and retry) - verify(createAuthorizationSession, times(2)).invoke( + // creates an additional session (cancel triggers a retry) + verify(createAuthorizationSession).invoke( eq(activeInstitution), eq(syncResponse) ) @@ -295,6 +294,7 @@ internal class PartnerAuthViewModelTest { initialState = initialState, browserManager = mock(), uriUtils = UriUtils(Logger.noop(), mock()), + handleError = handleError, applicationId = applicationId ) } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/success/SuccessViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/success/SuccessViewModelTest.kt index 9d90109c64c..f7b0531cd6a 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/success/SuccessViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/success/SuccessViewModelTest.kt @@ -5,7 +5,6 @@ import com.airbnb.mvrx.test.MavericksTestRule import com.google.common.truth.Truth.assertThat import com.stripe.android.core.Logger import com.stripe.android.financialconnections.ApiKeyFixtures -import com.stripe.android.financialconnections.R import com.stripe.android.financialconnections.TestFinancialConnectionsAnalyticsTracker import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded import com.stripe.android.financialconnections.domain.GetCachedAccounts @@ -13,8 +12,6 @@ import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator.Message.Complete import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane -import com.stripe.android.financialconnections.repository.SaveToLinkWithStripeSucceededRepository -import com.stripe.android.financialconnections.ui.TextResource.PluralId import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -38,7 +35,6 @@ internal class SuccessViewModelTest { private val eventTracker = TestFinancialConnectionsAnalyticsTracker() private val nativeAuthFlowCoordinator = mock () private val getCachedAccounts = mock () - private val saveToLinkWithStripeSucceeded = mock () private fun buildViewModel( state: SuccessState @@ -48,8 +44,8 @@ internal class SuccessViewModelTest { eventTracker = eventTracker, initialState = state, nativeAuthFlowCoordinator = nativeAuthFlowCoordinator, + saveToLinkWithStripeSucceeded = mock(), getCachedAccounts = getCachedAccounts, - saveToLinkWithStripeSucceeded = saveToLinkWithStripeSucceeded, ) @Test @@ -108,163 +104,4 @@ internal class SuccessViewModelTest { ) } } - - @Test - fun `getSuccessMessage - link with stripe and connected account name and business name`() { - val result = buildViewModel(SuccessState()).getSuccessMessages( - isLinkWithStripe = true, - isNetworkingUserFlow = null, - saveToLinkWithStripeSucceeded = true, - connectedAccountName = "Connected Account Name", - businessName = "Business Name", - count = 2 - ) as PluralId - assertEquals(R.plurals.stripe_success_pane_link_with_connected_account_name, result.value) - assertEquals(listOf("Connected Account Name", "Business Name"), result.args) - assertEquals(2, result.count) - } - - @Test - fun `getSuccessMessage - link with stripe and business name`() { - val result = buildViewModel(SuccessState()).getSuccessMessages( - isLinkWithStripe = true, - isNetworkingUserFlow = null, - saveToLinkWithStripeSucceeded = true, - connectedAccountName = null, - businessName = "Business Name", - count = 3 - ) as PluralId - assertEquals(R.plurals.stripe_success_pane_link_with_business_name, result.value) - assertEquals(listOf("Business Name"), result.args) - assertEquals(3, result.count) - } - - @Test - fun `getSuccessMessage - link with stripe and no business name`() { - val result = buildViewModel(SuccessState()).getSuccessMessages( - isLinkWithStripe = true, - isNetworkingUserFlow = null, - saveToLinkWithStripeSucceeded = true, - connectedAccountName = null, - businessName = null, - count = 1 - ) as PluralId - assertEquals(R.plurals.stripe_success_pane_link_with_no_business_name, result.value) - assertEquals(listOf (), result.args) - assertEquals(1, result.count) - } - - @Test - fun `getSuccessMessage - networking user flow and save to link with stripe succeeded and connected account name and business name`() { - val result = buildViewModel(SuccessState()).getSuccessMessages( - isLinkWithStripe = false, - isNetworkingUserFlow = true, - saveToLinkWithStripeSucceeded = true, - connectedAccountName = "Connected Account Name", - businessName = "Business Name", - count = 4 - ) as PluralId - assertEquals(R.plurals.stripe_success_pane_link_with_connected_account_name, result.value) - assertEquals(listOf("Connected Account Name", "Business Name"), result.args) - assertEquals(4, result.count) - } - - @Test - fun `getSuccessMessage - networking user flow and save to link with stripe succeeded and business name`() { - val result = buildViewModel(SuccessState()).getSuccessMessages( - isLinkWithStripe = false, - isNetworkingUserFlow = true, - saveToLinkWithStripeSucceeded = true, - connectedAccountName = null, - businessName = "Business Name", - count = 5 - ) as PluralId - assertEquals(R.plurals.stripe_success_pane_link_with_business_name, result.value) - assertEquals(listOf("Business Name"), result.args) - assertEquals(5, result.count) - } - - @Test - fun `getSuccessMessage - no link with stripe and connected account name and business name`() { - val result = buildViewModel(SuccessState()).getSuccessMessages( - isLinkWithStripe = false, - isNetworkingUserFlow = null, - saveToLinkWithStripeSucceeded = null, - connectedAccountName = "Connected Account Name", - businessName = "Business Name", - count = 6 - ) as PluralId - assertEquals(R.plurals.stripe_success_pane_has_connected_account_name, result.value) - assertEquals(listOf("Connected Account Name", "Business Name"), result.args) - assertEquals(6, result.count) - } - - @Test - fun `getSuccessMessage - no link with stripe and business name`() { - val result = buildViewModel(SuccessState()).getSuccessMessages( - isLinkWithStripe = false, - isNetworkingUserFlow = null, - saveToLinkWithStripeSucceeded = null, - connectedAccountName = null, - businessName = "Business Name", - count = 7 - ) as PluralId - assertEquals(R.plurals.stripe_success_pane_has_business_name, result.value) - assertEquals(listOf("Business Name"), result.args) - assertEquals(7, result.count) - } - - @Test - fun `getSuccessMessage - no link with stripe and no business name`() { - val result = buildViewModel(SuccessState()).getSuccessMessages( - isLinkWithStripe = false, - isNetworkingUserFlow = null, - saveToLinkWithStripeSucceeded = null, - connectedAccountName = null, - businessName = null, - count = 8 - ) as PluralId - assertEquals(R.plurals.stripe_success_pane_no_business_name, result.value) - assertEquals(listOf (), result.args) - assertEquals(8, result.count) - } - - @Test - fun `getFailedToLinkMessage - saveToLinkWithStripeSucceeded is true, should return null`() { - val message = buildViewModel(SuccessState()).getFailedToLinkMessage( - businessName = "Business", - saveToLinkWithStripeSucceeded = true, - count = 1 - ) - assertThat(message).isNull() - } - - @Test - fun `getFailedToLinkMessage - saveToLinkWithStripeSucceeded is false and businessName is not null`() { - val message = buildViewModel(SuccessState()).getFailedToLinkMessage( - businessName = "Business", - saveToLinkWithStripeSucceeded = false, - count = 1 - ) - require(message is PluralId) - assertEquals(R.plurals.stripe_success_networking_save_to_link_failed, message.value) - assertEquals(1, message.count) - assertEquals(listOf("Business"), message.args) - } - - @Test - fun `getFailedToLinkMessage - saveToLinkWithStripeSucceeded is false and businessName is null`() { - val message = buildViewModel(SuccessState()).getFailedToLinkMessage( - businessName = null, - saveToLinkWithStripeSucceeded = false, - count = 2 - ) - require(message is PluralId) - assertEquals( - R.plurals.stripe_success_pane_networking_save_to_link_failed_no_business, - message.value - ) - assertEquals(2, message.count) - assertEquals(emptyList(), message.args) - } } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModelTest.kt index 009edf83639..cb64b9551f3 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModelTest.kt @@ -100,9 +100,12 @@ internal class FinancialConnectionsSheetNativeViewModelTest { whenever(nativeAuthFlowCoordinator()).thenReturn(MutableSharedFlow()) whenever(completeFinancialConnectionsSession(anyOrNull())).thenReturn(sessionWithAccounts) + val messagesFlow = MutableSharedFlow () + whenever(nativeAuthFlowCoordinator()).thenReturn(messagesFlow) + val viewModel = createViewModel() - viewModel.onCloseConfirm() + messagesFlow.emit(Complete(null)) withState(viewModel) { require(it.viewEffect is Finish) @@ -121,12 +124,14 @@ internal class FinancialConnectionsSheetNativeViewModelTest { @Test fun `onCloseClick - when closing, no accounts, no errors, finish with cancel`() = runTest { val sessionWithNoAccounts = financialConnectionsSessionNoAccounts() - whenever(nativeAuthFlowCoordinator()).thenReturn(MutableSharedFlow()) whenever(completeFinancialConnectionsSession(anyOrNull())).thenReturn(sessionWithNoAccounts) + val messagesFlow = MutableSharedFlow () + whenever(nativeAuthFlowCoordinator()).thenReturn(messagesFlow) + val viewModel = createViewModel() - viewModel.onCloseConfirm() + messagesFlow.emit(Complete(null)) withState(viewModel) { require(it.viewEffect is Finish) @@ -151,12 +156,14 @@ internal class FinancialConnectionsSheetNativeViewModelTest { ) ) ) - whenever(nativeAuthFlowCoordinator()).thenReturn(MutableSharedFlow()) whenever(completeFinancialConnectionsSession(anyOrNull())).thenReturn(sessionWithNoAccounts) + val messagesFlow = MutableSharedFlow () + whenever(nativeAuthFlowCoordinator()).thenReturn(messagesFlow) + val viewModel = createViewModel() - viewModel.onCloseConfirm() + messagesFlow.emit(Complete(null)) withState(viewModel) { require(it.viewEffect is Finish) @@ -292,7 +299,6 @@ internal class FinancialConnectionsSheetNativeViewModelTest { completeFinancialConnectionsSession = completeFinancialConnectionsSession, nativeAuthFlowCoordinator = nativeAuthFlowCoordinator, logger = mock(), - getManifest = mock(), navigationManager = TestNavigationManager(), initialState = initialState ) diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/screenshottests/FinancialConnectionsShotShowkaseScreenshotTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/screenshottests/FinancialConnectionsShotShowkaseScreenshotTest.kt index 082c27b4933..3ba6c9ea9f3 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/screenshottests/FinancialConnectionsShotShowkaseScreenshotTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/screenshottests/FinancialConnectionsShotShowkaseScreenshotTest.kt @@ -12,6 +12,7 @@ import com.airbnb.android.showkase.models.ShowkaseBrowserComponent import com.google.testing.junit.testparameterinjector.TestParameter import com.google.testing.junit.testparameterinjector.TestParameterInjector import com.stripe.android.financialconnections.getMetadata +import com.stripe.android.financialconnections.utils.TimeZoneRule import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -34,6 +35,9 @@ class PaparazziSampleScreenshotTest { PIXEL_C(DeviceConfig.PIXEL_C), } + @get:Rule + val timeZoneRule = TimeZoneRule() + @get:Rule val paparazzi = Paparazzi( environment = detectEnvironment().run { diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/TestHandleError.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/TestHandleError.kt new file mode 100644 index 00000000000..6cf955879be --- /dev/null +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/TestHandleError.kt @@ -0,0 +1,50 @@ +package com.stripe.android.financialconnections.utils + +import com.stripe.android.financialconnections.domain.HandleError +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest + +internal class TestHandleError : HandleError { + + private val invocations = mutableListOf