Skip to content

Commit

Permalink
[connect] Update background and loading spinner colors (#9670)
Browse files Browse the repository at this point in the history
* update background and loading indicator colors

* lint

* fix test

* internal ColorUtils
  • Loading branch information
lng-stripe authored Dec 2, 2024
1 parent ef97c7e commit 4859229
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.stripe.android.connect.util

import android.graphics.Color
import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils
import kotlin.math.max
import kotlin.math.min

/**
* Returns a color for minimum contrast with a given background color
*
* Reference: [WCAG 2.1 Contrast Minimum](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-contrast-ratio)
*
* @param color The background color with which to get minimum contrast
* @param minimumRatio The minimum contrast ratio (defaults to WCAG minimum ratio of 4.5)
* @return The adjusted color that meets the minimum contrast ratio
*/
@Suppress("MagicNumber", "ComplexCondition")
@ColorInt
internal fun getContrastingColor(@ColorInt color: Int, minimumRatio: Float = 4.5f): Int {
var adjustedColor = color

val shouldLighten = ColorUtils.calculateLuminance(color) < 0.5
val hsv = FloatArray(3)
Color.colorToHSV(adjustedColor, hsv)

while (
ColorUtils.calculateContrast(adjustedColor, color) < minimumRatio &&
((shouldLighten && hsv[2] < 1f) || (!shouldLighten && hsv[2] > 0f))
) {
if (shouldLighten) {
hsv[2] = min(1f, hsv[2] + HSV_VALUE_STEP_SIZE)
} else {
hsv[2] = max(0f, hsv[2] - HSV_VALUE_STEP_SIZE)
}
adjustedColor = Color.HSVToColor(hsv)
}

return adjustedColor
}

private const val HSV_VALUE_STEP_SIZE = 0.1f
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.stripe.android.connect.webview

import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.graphics.Bitmap
import android.view.LayoutInflater
import android.webkit.JavascriptInterface
Expand Down Expand Up @@ -175,7 +176,13 @@ internal class StripeConnectWebViewContainerImpl(
}

private fun bindViewState(state: StripeConnectWebViewContainerState) {
viewBinding?.stripeWebViewProgressBar?.isVisible = state.isNativeLoadingIndicatorVisible
val viewBinding = this.viewBinding ?: return
viewBinding.stripeWebView.setBackgroundColor(state.backgroundColor)
viewBinding.stripeWebViewProgressBar.isVisible = state.isNativeLoadingIndicatorVisible
if (state.isNativeLoadingIndicatorVisible) {
viewBinding.stripeWebViewProgressBar.indeterminateTintList =
ColorStateList.valueOf(state.nativeLoadingIndicatorColor)
}
}

@VisibleForTesting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ internal class StripeConnectWebViewContainerController(
// Bind appearance changes in the manager to the WebView (only when page is loaded).
embeddedComponentManager.appearanceFlow
.collectLatest { appearance ->
updateState { copy(appearance = appearance) }
if (stateFlow.value.receivedPageDidLoad) {
view.updateConnectInstance(appearance)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
package com.stripe.android.connect.webview

import android.graphics.Color
import androidx.annotation.ColorInt
import com.stripe.android.connect.PrivateBetaConnectSDK
import com.stripe.android.connect.appearance.Appearance
import com.stripe.android.connect.util.getContrastingColor

@OptIn(PrivateBetaConnectSDK::class)
internal data class StripeConnectWebViewContainerState(
/**
* True if we received the 'pageDidLoad' message.
Expand All @@ -15,4 +22,23 @@ internal data class StripeConnectWebViewContainerState(
* True if the native loading indicator should be visible.
*/
val isNativeLoadingIndicatorVisible: Boolean = false,
)

/**
* The appearance to use for the view.
*/
val appearance: Appearance? = null
) {
/**
* The background color of the view.
*/
@ColorInt
val backgroundColor: Int =
appearance?.colors?.background ?: Color.WHITE

/**
* The color of the native loading indicator.
*/
@ColorInt
val nativeLoadingIndicatorColor: Int =
appearance?.colors?.secondaryText ?: getContrastingColor(backgroundColor, 4.5f)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.stripe.android.connect.util

import android.graphics.Color
import androidx.core.graphics.ColorUtils
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class GetContrastingColorTest {
@Test
fun `should not loop infinitely`() {
val midGray = Color.argb(1f, .5f, .5f, .5f)

// The maximum contrast ratio to mid-gray is 5.28, ensure that this
// returns a color with the maximum contrast ratio that can be achieved
val color = getContrastingColor(midGray, 5.5f)
assertThat(ColorUtils.calculateContrast(color, midGray)).isLessThan(5.5)
assertThat(color).isEqualTo(Color.WHITE)
}

@Test
fun `should return a contrasting color`() {
listOf(
Color.WHITE,
Color.LTGRAY,
Color.CYAN,
Color.DKGRAY,
Color.BLACK,
).forEach { bgColor ->
val color = getContrastingColor(bgColor, 4.5f)
val contrast = ColorUtils.calculateContrast(color, bgColor)
assertThat(contrast).isGreaterThan(4.5)
assertThat(contrast).isLessThan(6.0)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ import com.stripe.android.core.version.StripeSdkVersion
import kotlinx.serialization.json.Json
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner

@OptIn(PrivateBetaConnectSDK::class)
@RunWith(RobolectricTestRunner::class)
class StripeConnectWebViewClientTest {

private val mockContext: Context = mock()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
package com.stripe.android.connect.webview

import android.content.Context
import android.graphics.Color
import android.net.Uri
import android.webkit.WebResourceRequest
import androidx.lifecycle.testing.TestLifecycleOwner
import com.google.common.truth.Truth.assertThat
import com.stripe.android.connect.EmbeddedComponentManager
import com.stripe.android.connect.EmbeddedComponentManager.Configuration
import com.stripe.android.connect.PrivateBetaConnectSDK
import com.stripe.android.connect.StripeEmbeddedComponent
import com.stripe.android.connect.appearance.Appearance
import com.stripe.android.connect.appearance.Colors
import com.stripe.android.core.Logger
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertFalse
Expand All @@ -31,6 +40,7 @@ class StripeConnectWebViewContainerControllerTest {
private val mockStripeIntentLauncher: StripeIntentLauncher = mock()
private val mockLogger: Logger = mock()

private val lifecycleOwner = TestLifecycleOwner()
private lateinit var controller: StripeConnectWebViewContainerController

@Before
Expand Down Expand Up @@ -99,4 +109,39 @@ class StripeConnectWebViewContainerControllerTest {
verify(mockStripeIntentLauncher).launchUrlWithSystemHandler(mockContext, uri)
assertTrue(result)
}

fun `should bind to appearance changes`() = runTest {
assertThat(controller.stateFlow.value.appearance).isNull()

controller.onCreate(lifecycleOwner)
val newAppearance = Appearance()
embeddedComponentManager.update(newAppearance)

assertThat(controller.stateFlow.value.appearance).isEqualTo(newAppearance)
}

@Test
fun `view should update appearance`() = runTest {
val appearances = listOf(Appearance(), Appearance(colors = Colors(primary = Color.CYAN)))
controller.onCreate(lifecycleOwner)

// Shouldn't update appearance until pageDidLoad is received.
verify(view, never()).updateConnectInstance(any())

embeddedComponentManager.update(appearances[0])
controller.onViewAttached()
controller.onPageStarted()
verify(view, never()).updateConnectInstance(any())

// Should update appearance when pageDidLoad is received.
controller.onReceivedPageDidLoad()

// Should update again when appearance changes.
embeddedComponentManager.update(appearances[1])

inOrder(view) {
verify(view).updateConnectInstance(appearances[0])
verify(view).updateConnectInstance(appearances[1])
}
}
}

0 comments on commit 4859229

Please sign in to comment.