diff --git a/dependencies.gradle b/dependencies.gradle index a379449aade..5ae4b23a11b 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -69,6 +69,7 @@ ext.versions = [ tensorflowLite : '2.11.0', tensorflowLiteSupport : '0.4.3', testParameterInjector : '1.12', + timber : '5.0.1', truth : '1.1.3', turbine : '1.0.0', uiAutomator : '2.2.0', @@ -189,6 +190,7 @@ ext.libs = [ tensorflowLiteSupport : "org.tensorflow:tensorflow-lite-support:${versions.tensorflowLiteSupport}", tensorflowLitePlayServices : "com.google.android.gms:play-services-tflite-java:${versions.playServicesTfLite}", tensorflowLitePlayServicesSupport : "com.google.android.gms:play-services-tflite-support:${versions.playServicesTfLite}", + timber : "com.jakewharton.timber:timber:${versions.timber}", zxing : "com.google.zxing:core:${versions.zxing}", ] diff --git a/stripe-connect-example/build.gradle b/stripe-connect-example/build.gradle index ded7e21bed0..c547cabbacd 100644 --- a/stripe-connect-example/build.gradle +++ b/stripe-connect-example/build.gradle @@ -6,12 +6,20 @@ apply plugin: 'org.jetbrains.kotlin.plugin.serialization' dependencies { implementation project(":stripe-connect") + implementation project(':stripe-core') // Kotlin implementation libs.kotlin.coroutines implementation libs.kotlin.coroutinesAndroid implementation libs.kotlin.serialization + // Networking + implementation libs.fuel + implementation libs.fuelCoroutines + + // Logging + implementation libs.timber + // AndroidX implementation libs.androidx.activity implementation libs.androidx.annotation diff --git a/stripe-connect-example/src/main/AndroidManifest.xml b/stripe-connect-example/src/main/AndroidManifest.xml index eaae5a28c77..889d6fd4ce8 100644 --- a/stripe-connect-example/src/main/AndroidManifest.xml +++ b/stripe-connect-example/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ - + + + + + diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/App.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/App.kt new file mode 100644 index 00000000000..66f65ba61df --- /dev/null +++ b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/App.kt @@ -0,0 +1,32 @@ +package com.stripe.android.connectsdk.example + +import android.app.Application +import android.os.StrictMode +import timber.log.Timber + +class App: Application() { + override fun onCreate() { + super.onCreate() + + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectAll() + .penaltyLog() + .build() + ) + + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .penaltyLog() + .build() + ) + + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + } +} \ No newline at end of file diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/MainActivity.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/MainActivity.kt index c73c5a2912d..1e36b5ff368 100644 --- a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/MainActivity.kt +++ b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/MainActivity.kt @@ -34,8 +34,8 @@ import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.stripe.android.connectsdk.example.ui.accountonboarding.AccountOnboardingExampleActivity -import com.stripe.android.connectsdk.example.ui.payouts.PayoutsExampleActivity +import com.stripe.android.connectsdk.example.ui.features.accountonboarding.AccountOnboardingExampleActivity +import com.stripe.android.connectsdk.example.ui.features.payouts.PayoutsExampleActivity class MainActivity : ComponentActivity() { diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/networking/EmbeddedComponentService.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/networking/EmbeddedComponentService.kt index f13a68aef8b..9cb17ddadf2 100644 --- a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/networking/EmbeddedComponentService.kt +++ b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/networking/EmbeddedComponentService.kt @@ -1,11 +1,89 @@ package com.stripe.android.connectsdk.example.networking -import kotlinx.coroutines.delay +import com.github.kittinunf.fuel.core.Deserializable +import com.github.kittinunf.fuel.core.FuelError +import com.github.kittinunf.fuel.core.FuelManager +import com.github.kittinunf.fuel.core.Request +import com.github.kittinunf.fuel.core.Response +import com.github.kittinunf.fuel.core.awaitResult +import com.github.kittinunf.result.Result +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json class EmbeddedComponentService { - suspend fun fetchClientSecret(): String? { - // TODO MXMOBILE-2511 - add backend call - delay(3000) - return null + private val fuel = FuelManager.instance + .apply { + // add logging + addRequestInterceptor(TimberRequestLogger("EmbeddedComponentService")) + addResponseInterceptor(TimberResponseLogger("EmbeddedComponentService")) + + // add headers + addRequestInterceptor(ApplicationJsonHeaderInterceptor) + addRequestInterceptor(UserAgentHeader) + } + + /** + * Returns the publishable key for use in the Stripe Connect SDK as well as a list + * of available merchants. Throws a [FuelError] exception on network issues and other errors. + */ + suspend fun getAccounts(): GetAccountsResponse { + return fuel.get(EXAMPLE_BACKEND_URL + "app_info") + .awaitModel(GetAccountsResponse.serializer()) + .get() + } + + /** + * Returns the client secret for the given merchant account to be used in the Stripe Connect SDK. + * Throws a [FuelError] exception on network issues and other errors. + */ + suspend fun fetchClientSecret(account: String): String { + return fuel.post(EXAMPLE_BACKEND_URL + "account_session") + .header("account", account) + .awaitModel(FetchClientSecretResponse.serializer()) + .get() + .clientSecret + } + + companion object { + private const val EXAMPLE_BACKEND_URL = "https://stripe-connect-mobile-example-v1.glitch.me/" } } + +@Serializable +data class FetchClientSecretResponse( + @SerialName("client_secret") + val clientSecret: String +) + +@Serializable +data class GetAccountsResponse( + @SerialName("publishable_key") + val publishableKey: String, + @SerialName("available_merchants") + val availableMerchants: List +) + +@Serializable +data class Merchant( + @SerialName("merchant_id") + val merchantId: String, + @SerialName("display_name") + val displayName: String +) + +suspend fun Request.awaitModel( + serializer: DeserializationStrategy +): Result { + val deserializer = object : Deserializable { + + override fun deserialize(response: Response): T { + println(response.toString()) + val body = response.body().asString("application/json") + return Json.decodeFromString(serializer, body) + } + } + + return awaitResult(deserializer) +} \ No newline at end of file diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/networking/NetworkingInterceptors.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/networking/NetworkingInterceptors.kt new file mode 100644 index 00000000000..f0afb67df38 --- /dev/null +++ b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/networking/NetworkingInterceptors.kt @@ -0,0 +1,59 @@ +package com.stripe.android.connectsdk.example.networking + +import android.os.Build +import com.github.kittinunf.fuel.core.FoldableRequestInterceptor +import com.github.kittinunf.fuel.core.FoldableResponseInterceptor +import com.github.kittinunf.fuel.core.RequestTransformer +import com.github.kittinunf.fuel.core.ResponseTransformer +import com.github.kittinunf.fuel.core.extensions.cUrlString +import com.stripe.android.core.version.StripeSdkVersion +import timber.log.Timber + +object ApplicationJsonHeaderInterceptor : FoldableRequestInterceptor { + override fun invoke(next: RequestTransformer): RequestTransformer { + return { request -> + next(request.header("content-type", "application/json")) + } + } +} + +object UserAgentHeader : FoldableRequestInterceptor { + fun getUserAgent(): String { + val androidBrand = Build.BRAND + val androidDevice = Build.MODEL + val osVersion = Build.VERSION.SDK_INT + return buildString { + append("Stripe/ConnectSDKExample") + append(" (Android $androidBrand $androidDevice; (OS Version $osVersion))+") + append(" Version/${StripeSdkVersion.VERSION_NAME}") + } + } + + override fun invoke(next: RequestTransformer): RequestTransformer { + return { request -> + next(request.header("User-Agent", getUserAgent())) + } + } +} + +class TimberRequestLogger(private val tag: String) : FoldableRequestInterceptor { + private val timber get() = Timber.tag(tag) + + override fun invoke(next: RequestTransformer): RequestTransformer { + return { request -> + timber.i("Request: ${request.cUrlString()}") + next(request) + } + } +} + +class TimberResponseLogger(private val tag: String) : FoldableResponseInterceptor { + private val timber get() = Timber.tag(tag) + + override fun invoke(next: ResponseTransformer): ResponseTransformer { + return { request, response -> + timber.i("Response: $response") + next(request, response) + } + } +} \ No newline at end of file diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/accountonboarding/AccountOnboardingExampleActivity.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/accountonboarding/AccountOnboardingExampleActivity.kt deleted file mode 100644 index c9101daf369..00000000000 --- a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/accountonboarding/AccountOnboardingExampleActivity.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.stripe.android.connectsdk.example.ui.accountonboarding - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Button -import androidx.compose.material.Text -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel -import com.stripe.android.connectsdk.EmbeddedComponentManager -import com.stripe.android.connectsdk.PrivateBetaConnectSDK -import com.stripe.android.connectsdk.example.ConnectSdkExampleTheme -import com.stripe.android.connectsdk.example.MainContent - -class AccountOnboardingExampleActivity : ComponentActivity() { - - @OptIn(PrivateBetaConnectSDK::class) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - ConnectSdkExampleTheme { - val viewModel: AccountOnboardingExampleViewModel = viewModel() - val embeddedComponentManager = remember { - EmbeddedComponentManager( - activity = this@AccountOnboardingExampleActivity, - // TODO MXMOBILE-2511 - pass publishable key from backend to SDK - configuration = EmbeddedComponentManager.Configuration(""), - fetchClientSecret = viewModel::fetchClientSecret, - ) - } - - MainContent(title = "Account Onboarding Example") { - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Button( - onClick = embeddedComponentManager::presentAccountOnboarding, - ) { - Text("Launch onboarding") - } - } - } - } - } - } -} diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/accountonboarding/AccountOnboardingExampleViewModel.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/accountonboarding/AccountOnboardingExampleViewModel.kt deleted file mode 100644 index d46c3888e15..00000000000 --- a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/accountonboarding/AccountOnboardingExampleViewModel.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.stripe.android.connectsdk.example.ui.accountonboarding - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.stripe.android.connectsdk.FetchClientSecretCallback.ClientSecretResultCallback -import com.stripe.android.connectsdk.PrivateBetaConnectSDK -import com.stripe.android.connectsdk.example.networking.EmbeddedComponentService -import kotlinx.coroutines.launch - -class AccountOnboardingExampleViewModel( - private val embeddedComponentService: EmbeddedComponentService = EmbeddedComponentService(), -): ViewModel() { - - @OptIn(PrivateBetaConnectSDK::class) - fun fetchClientSecret(resultCallback: ClientSecretResultCallback) { - viewModelScope.launch { - val clientSecret = embeddedComponentService.fetchClientSecret() - if (clientSecret != null) { - resultCallback.onResult(clientSecret) - } else { - resultCallback.onError() - } - } - } -} \ No newline at end of file diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/common/EmbeddedComponentsUi.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/common/EmbeddedComponentsUi.kt new file mode 100644 index 00000000000..b0c4b6dd67b --- /dev/null +++ b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/common/EmbeddedComponentsUi.kt @@ -0,0 +1,139 @@ +package com.stripe.android.connectsdk.example.ui.common + +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.material.Button +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.material.ExposedDropdownMenuDefaults +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.stripe.android.connectsdk.example.networking.Merchant + +@Composable +fun LaunchEmbeddedComponentsScreen( + embeddedComponentName: String, + selectedAccount: Merchant?, + connectSDKAccounts: List, + onConnectSDKAccountSelected: (Merchant) -> Unit, + onEmbeddedComponentLaunched: () -> Unit, +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AccountSelector( + selectedAccount = selectedAccount, + accounts = connectSDKAccounts, + onAccountSelected = onConnectSDKAccountSelected, + ) + Button( + onClick = onEmbeddedComponentLaunched, + enabled = selectedAccount != null, + ) { + Text("Launch $embeddedComponentName") + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AccountSelector( + selectedAccount: Merchant? = null, + accounts: List, + onAccountSelected: (Merchant) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextField( + readOnly = true, + value = selectedAccount?.displayName ?: "No account selected", + onValueChange = {}, + label = { Text("Account") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + for (account in accounts) { + DropdownMenuItem( + onClick = { + onAccountSelected(account) + expanded = false + } + ) { + Text(text = account.displayName,) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun LaunchEmbeddedComponentsScreenPreviewWithSelectedAccount() { + LaunchEmbeddedComponentsScreen( + embeddedComponentName = "Payouts", + selectedAccount = Merchant(merchantId = "1", displayName = "Selected Merchant"), + connectSDKAccounts = listOf( + Merchant(merchantId = "2", displayName = "Merchant 1"), + Merchant(merchantId = "3", displayName = "Merchant 2"), + Merchant(merchantId = "4", displayName = "Merchant 3") + ), + onConnectSDKAccountSelected = {}, + onEmbeddedComponentLaunched = {} + ) +} + +@Preview(showBackground = true) +@Composable +fun LaunchEmbeddedComponentsScreenPreviewWithNoSelectedAccount() { + LaunchEmbeddedComponentsScreen( + embeddedComponentName = "Payouts", + selectedAccount = null, + connectSDKAccounts = listOf( + Merchant(merchantId = "2", displayName = "Merchant 1"), + Merchant(merchantId = "3", displayName = "Merchant 2"), + Merchant(merchantId = "4", displayName = "Merchant 3") + ), + onConnectSDKAccountSelected = {}, + onEmbeddedComponentLaunched = {} + ) +} + +@Preview(showBackground = true) +@Composable +fun LaunchEmbeddedComponentsScreenPreviewWithEmptyAccounts() { + LaunchEmbeddedComponentsScreen( + embeddedComponentName = "Payouts", + selectedAccount = Merchant(merchantId = "1", displayName = "Selected Merchant"), + connectSDKAccounts = emptyList(), + onConnectSDKAccountSelected = {}, + onEmbeddedComponentLaunched = {} + ) +} \ No newline at end of file diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/accountonboarding/AccountOnboardingExampleActivity.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/accountonboarding/AccountOnboardingExampleActivity.kt new file mode 100644 index 00000000000..de2d6baf667 --- /dev/null +++ b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/accountonboarding/AccountOnboardingExampleActivity.kt @@ -0,0 +1,64 @@ +package com.stripe.android.connectsdk.example.ui.features.accountonboarding + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.stripe.android.connectsdk.EmbeddedComponentManager +import com.stripe.android.connectsdk.EmbeddedComponentManager.Configuration +import com.stripe.android.connectsdk.PrivateBetaConnectSDK +import com.stripe.android.connectsdk.example.ConnectSdkExampleTheme +import com.stripe.android.connectsdk.example.MainContent +import com.stripe.android.connectsdk.example.ui.common.LaunchEmbeddedComponentsScreen + +class AccountOnboardingExampleActivity : ComponentActivity() { + + @OptIn(PrivateBetaConnectSDK::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ConnectSdkExampleTheme { + val viewModel: AccountOnboardingExampleViewModel = viewModel() + val accountOnboardingExampleState by viewModel.state.collectAsState() + val sdkPublishableKey = accountOnboardingExampleState.publishableKey + val accounts = accountOnboardingExampleState.accounts + + MainContent(title = "Account Onboarding Example") { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (sdkPublishableKey != null && accounts != null) { + val embeddedComponentManager = remember(sdkPublishableKey) { + EmbeddedComponentManager( + activity = this@AccountOnboardingExampleActivity, + configuration = Configuration(sdkPublishableKey), + fetchClientSecret = viewModel::fetchClientSecret, + ) + } + + LaunchEmbeddedComponentsScreen( + embeddedComponentName = "Account Onboarding", + selectedAccount = accountOnboardingExampleState.selectedAccount, + connectSDKAccounts = accounts, + onConnectSDKAccountSelected = viewModel::onAccountSelected, + onEmbeddedComponentLaunched = embeddedComponentManager::presentAccountOnboarding, + ) + } else { + CircularProgressIndicator() + } + } + } + } + } + } +} \ No newline at end of file diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/accountonboarding/AccountOnboardingExampleViewModel.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/accountonboarding/AccountOnboardingExampleViewModel.kt new file mode 100644 index 00000000000..59501186f78 --- /dev/null +++ b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/accountonboarding/AccountOnboardingExampleViewModel.kt @@ -0,0 +1,73 @@ +package com.stripe.android.connectsdk.example.ui.features.accountonboarding + +import androidx.lifecycle.ViewModel +import com.github.kittinunf.fuel.core.FuelError +import com.stripe.android.connectsdk.FetchClientSecretCallback.ClientSecretResultCallback +import com.stripe.android.connectsdk.PrivateBetaConnectSDK +import com.stripe.android.connectsdk.example.networking.EmbeddedComponentService +import com.stripe.android.connectsdk.example.networking.Merchant +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber + +class AccountOnboardingExampleViewModel( + private val embeddedComponentService: EmbeddedComponentService = EmbeddedComponentService(), + private val networkingScope: CoroutineScope = CoroutineScope(Dispatchers.IO), +): ViewModel() { + + private val timber get() = Timber.tag("AccountOnboardingExampleViewModel") + + private val _state = MutableStateFlow(PayoutsExampleState()) + val state: StateFlow = _state.asStateFlow() + + init { + getAccounts() + } + + @OptIn(PrivateBetaConnectSDK::class) + fun fetchClientSecret(resultCallback: ClientSecretResultCallback) { + val account = _state.value.selectedAccount ?: return + networkingScope.launch { + try { + val clientSecret = embeddedComponentService.fetchClientSecret(account.merchantId) + resultCallback.onResult(clientSecret) + } catch (e: FuelError) { + resultCallback.onError() + timber.e("Error fetching client secret: $e") + } + } + } + + fun onAccountSelected(account: Merchant) { + _state.update { + it.copy(selectedAccount = account) + } + } + + private fun getAccounts() { + networkingScope.launch { + try { + val response = embeddedComponentService.getAccounts() + _state.update { + it.copy( + publishableKey = response.publishableKey, + accounts = response.availableMerchants, + ) + } + } catch (e: FuelError) { + timber.e("Error getting accounts: $e") + } + } + } + + data class PayoutsExampleState( + val selectedAccount: Merchant? = null, + val accounts: List? = null, + val publishableKey: String? = null, + ) +} \ No newline at end of file diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/payouts/PayoutsExampleActivity.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/payouts/PayoutsExampleActivity.kt new file mode 100644 index 00000000000..ac909d0c9c0 --- /dev/null +++ b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/payouts/PayoutsExampleActivity.kt @@ -0,0 +1,64 @@ +package com.stripe.android.connectsdk.example.ui.features.payouts + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.stripe.android.connectsdk.EmbeddedComponentManager +import com.stripe.android.connectsdk.EmbeddedComponentManager.Configuration +import com.stripe.android.connectsdk.PrivateBetaConnectSDK +import com.stripe.android.connectsdk.example.ConnectSdkExampleTheme +import com.stripe.android.connectsdk.example.MainContent +import com.stripe.android.connectsdk.example.ui.common.LaunchEmbeddedComponentsScreen + +@OptIn(PrivateBetaConnectSDK::class) +class PayoutsExampleActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ConnectSdkExampleTheme { + val viewModel: PayoutsExampleViewModel = viewModel() + val payoutsExampleState by viewModel.state.collectAsState() + val sdkPublishableKey = payoutsExampleState.publishableKey + val accounts = payoutsExampleState.accounts + + MainContent(title = "Payouts Example") { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (sdkPublishableKey != null && accounts != null) { + val embeddedComponentManager = remember(sdkPublishableKey) { + EmbeddedComponentManager( + activity = this@PayoutsExampleActivity, + configuration = Configuration(sdkPublishableKey), + fetchClientSecret = viewModel::fetchClientSecret, + ) + } + + LaunchEmbeddedComponentsScreen( + embeddedComponentName = "Payouts", + selectedAccount = payoutsExampleState.selectedAccount, + connectSDKAccounts = accounts, + onConnectSDKAccountSelected = viewModel::onAccountSelected, + onEmbeddedComponentLaunched = embeddedComponentManager::presentPayouts, + ) + } else { + CircularProgressIndicator() + } + } + } + } + } + } +} diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/payouts/PayoutsExampleViewModel.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/payouts/PayoutsExampleViewModel.kt new file mode 100644 index 00000000000..25e23146ba1 --- /dev/null +++ b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/features/payouts/PayoutsExampleViewModel.kt @@ -0,0 +1,73 @@ +package com.stripe.android.connectsdk.example.ui.features.payouts + +import androidx.lifecycle.ViewModel +import com.github.kittinunf.fuel.core.FuelError +import com.stripe.android.connectsdk.FetchClientSecretCallback.ClientSecretResultCallback +import com.stripe.android.connectsdk.PrivateBetaConnectSDK +import com.stripe.android.connectsdk.example.networking.EmbeddedComponentService +import com.stripe.android.connectsdk.example.networking.Merchant +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber + +class PayoutsExampleViewModel( + private val embeddedComponentService: EmbeddedComponentService = EmbeddedComponentService(), + private val networkingScope: CoroutineScope = CoroutineScope(Dispatchers.IO), +): ViewModel() { + + private val timber get() = Timber.tag("PayoutsExampleViewModel") + + private val _state = MutableStateFlow(PayoutsExampleState()) + val state: StateFlow = _state.asStateFlow() + + init { + getAccounts() + } + + @OptIn(PrivateBetaConnectSDK::class) + fun fetchClientSecret(resultCallback: ClientSecretResultCallback) { + val account = _state.value.selectedAccount ?: return + networkingScope.launch { + try { + val clientSecret = embeddedComponentService.fetchClientSecret(account.merchantId) + resultCallback.onResult(clientSecret) + } catch (e: FuelError) { + resultCallback.onError() + timber.e("Error fetching client secret: $e") + } + } + } + + fun onAccountSelected(account: Merchant) { + _state.update { + it.copy(selectedAccount = account) + } + } + + private fun getAccounts() { + networkingScope.launch { + try { + val response = embeddedComponentService.getAccounts() + _state.update { + it.copy( + publishableKey = response.publishableKey, + accounts = response.availableMerchants, + ) + } + } catch (e: FuelError) { + timber.e("Error getting accounts: $e") + } + } + } + + data class PayoutsExampleState( + val selectedAccount: Merchant? = null, + val accounts: List? = null, + val publishableKey: String? = null, + ) +} \ No newline at end of file diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/payouts/PayoutsExampleActivity.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/payouts/PayoutsExampleActivity.kt deleted file mode 100644 index 201c5579e8d..00000000000 --- a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/payouts/PayoutsExampleActivity.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.stripe.android.connectsdk.example.ui.payouts - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Button -import androidx.compose.material.Text -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel -import com.stripe.android.connectsdk.EmbeddedComponentManager -import com.stripe.android.connectsdk.PrivateBetaConnectSDK -import com.stripe.android.connectsdk.example.ConnectSdkExampleTheme -import com.stripe.android.connectsdk.example.MainContent - -@OptIn(PrivateBetaConnectSDK::class) -class PayoutsExampleActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - ConnectSdkExampleTheme { - val viewModel: PayoutsExampleViewModel = viewModel() - val embeddedComponentManager = remember { - EmbeddedComponentManager( - activity = this@PayoutsExampleActivity, - // TODO MXMOBILE-2511 - pass publishable key from backend to SDK - configuration = EmbeddedComponentManager.Configuration(""), - fetchClientSecret = viewModel::fetchClientSecret, - ) - } - - MainContent(title = "Payouts Example") { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Button( - onClick = embeddedComponentManager::presentPayouts, - ) { - Text("Launch payouts") - } - } - } - } - } - } -} diff --git a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/payouts/PayoutsExampleViewModel.kt b/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/payouts/PayoutsExampleViewModel.kt deleted file mode 100644 index 542a42f67fe..00000000000 --- a/stripe-connect-example/src/main/java/com/stripe/android/connectsdk/example/ui/payouts/PayoutsExampleViewModel.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.stripe.android.connectsdk.example.ui.payouts - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.stripe.android.connectsdk.FetchClientSecretCallback.ClientSecretResultCallback -import com.stripe.android.connectsdk.PrivateBetaConnectSDK -import com.stripe.android.connectsdk.example.networking.EmbeddedComponentService -import kotlinx.coroutines.launch - -class PayoutsExampleViewModel( - private val embeddedComponentService: EmbeddedComponentService = EmbeddedComponentService(), -): ViewModel() { - - @OptIn(PrivateBetaConnectSDK::class) - fun fetchClientSecret(resultCallback: ClientSecretResultCallback) { - viewModelScope.launch { - @OptIn(PrivateBetaConnectSDK::class) - fun fetchClientSecret(resultCallback: ClientSecretResultCallback) { - viewModelScope.launch { - val clientSecret = embeddedComponentService.fetchClientSecret() - if (clientSecret != null) { - resultCallback.onResult(clientSecret) - } else { - resultCallback.onError() - } - } - } - } - } -} \ No newline at end of file