PM-13471 Remove instances deprecated ClickableText (#4076)

This commit is contained in:
Dave Severns 2024-10-14 10:26:11 -04:00 committed by GitHub
parent bde47d7919
commit 4756040c4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 325 additions and 137 deletions

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
@ -27,10 +26,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -40,8 +36,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.checkemail.handlers.rememberCheckEmailHandler
import com.x8bit.bitwarden.ui.platform.base.util.ClickableTextHighlight
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.createClickableAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
@ -52,8 +50,6 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
private const val TAG_URL = "URL"
/**
* Top level composable for the check email screen.
*/
@ -282,54 +278,34 @@ private fun CheckEmailLegacyContent(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val goBackAnnotatedString = createAnnotatedString(
val goBackAnnotatedString = createClickableAnnotatedString(
mainString = stringResource(
id = R.string.no_email_go_back_to_edit_your_email_address,
),
highlights = listOf(stringResource(id = R.string.go_back)),
tag = TAG_URL,
highlights = listOf(
ClickableTextHighlight(
textToHighlight = stringResource(id = R.string.go_back),
onTextClick = onChangeEmailClick,
),
),
)
ClickableText(
Text(
text = goBackAnnotatedString,
onClick = {
goBackAnnotatedString
.getStringAnnotations(TAG_URL, it, it)
.firstOrNull()?.let {
onChangeEmailClick()
}
},
modifier = Modifier.semantics {
role = Role.Button
onClick {
onChangeEmailClick()
true
}
},
)
Spacer(modifier = Modifier.height(32.dp))
val logInAnnotatedString = createAnnotatedString(
val logInAnnotatedString = createClickableAnnotatedString(
mainString = stringResource(
id = R.string.or_log_in_you_may_already_have_an_account,
),
highlights = listOf(stringResource(id = R.string.log_in)),
tag = TAG_URL,
highlights = listOf(
ClickableTextHighlight(
textToHighlight = stringResource(id = R.string.log_in),
onTextClick = onLoginClick,
),
),
)
ClickableText(
Text(
text = logInAnnotatedString,
onClick = {
logInAnnotatedString
.getStringAnnotations(TAG_URL, it, it)
.firstOrNull()?.let {
onLoginClick()
}
},
modifier = Modifier.semantics {
role = Role.Button
onClick {
onLoginClick()
true
}
},
)
}
}

View file

@ -18,10 +18,10 @@ 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.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
@ -41,9 +41,6 @@ import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -59,9 +56,10 @@ import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEv
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToTerms
import com.x8bit.bitwarden.ui.auth.feature.startregistration.handlers.StartRegistrationHandler
import com.x8bit.bitwarden.ui.auth.feature.startregistration.handlers.rememberStartRegistrationHandler
import com.x8bit.bitwarden.ui.platform.base.util.ClickableTextHighlight
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.createClickableAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
@ -313,54 +311,22 @@ private fun TermsAndPrivacyText(
) {
val strTerms = stringResource(id = R.string.terms_of_service)
val strPrivacy = stringResource(id = R.string.privacy_policy)
val annotatedLinkString: AnnotatedString = buildAnnotatedString {
val strTermsAndPrivacy = stringResource(
id = R.string.by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy,
)
val startIndexTerms = strTermsAndPrivacy.indexOf(strTerms)
val endIndexTerms = startIndexTerms + strTerms.length
val startIndexPrivacy = strTermsAndPrivacy.indexOf(strPrivacy)
val endIndexPrivacy = startIndexPrivacy + strPrivacy.length
append(strTermsAndPrivacy)
addStyle(
style = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
val strTermsAndPrivacy = stringResource(
id = R.string.by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy,
)
val annotatedLinkString: AnnotatedString = createClickableAnnotatedString(
mainString = strTermsAndPrivacy,
highlights = listOf(
ClickableTextHighlight(
textToHighlight = strTerms,
onTextClick = onTermsClick,
),
start = 0,
end = strTermsAndPrivacy.length,
)
addStyle(
style = SpanStyle(
color = BitwardenTheme.colorScheme.text.interaction,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
ClickableTextHighlight(
textToHighlight = strPrivacy,
onTextClick = onPrivacyPolicyClick,
),
start = startIndexTerms,
end = endIndexTerms,
)
addStyle(
style = SpanStyle(
color = BitwardenTheme.colorScheme.text.interaction,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
start = startIndexPrivacy,
end = endIndexPrivacy,
)
addStringAnnotation(
tag = TAG_URL,
annotation = strTerms,
start = startIndexTerms,
end = endIndexTerms,
)
addStringAnnotation(
tag = TAG_URL,
annotation = strPrivacy,
start = startIndexPrivacy,
end = endIndexPrivacy,
)
}
),
)
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
@ -387,23 +353,11 @@ private fun TermsAndPrivacyText(
.padding(horizontal = 16.dp)
.fillMaxWidth(),
) {
val termsUrl = stringResource(id = R.string.terms_of_service)
ClickableText(
Text(
text = annotatedLinkString,
style = BitwardenTheme.typography.bodyMedium.copy(
textAlign = TextAlign.Center,
),
onClick = {
annotatedLinkString
.getStringAnnotations(TAG_URL, it, it)
.firstOrNull()?.let { stringAnnotation ->
if (stringAnnotation.item == termsUrl) {
onTermsClick()
} else {
onPrivacyPolicyClick()
}
}
},
)
}
}
@ -419,14 +373,13 @@ private fun ReceiveMarketingEmailsSwitch(
val unsubscribeString = stringResource(id = R.string.unsubscribe)
@Suppress("MaxLineLength")
val annotatedLinkString = createAnnotatedString(
val annotatedLinkString = createClickableAnnotatedString(
mainString = stringResource(id = R.string.get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time),
highlights = listOf(unsubscribeString),
tag = TAG_URL,
highlightStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.interaction,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
highlights = listOf(
ClickableTextHighlight(
textToHighlight = unsubscribeString,
onTextClick = onUnsubscribeClick,
),
),
)
Row(
@ -464,16 +417,9 @@ private fun ReceiveMarketingEmailsSwitch(
colors = bitwardenSwitchColors(),
)
Spacer(modifier = Modifier.width(16.dp))
ClickableText(
Text(
text = annotatedLinkString,
style = BitwardenTheme.typography.bodyMedium,
onClick = {
annotatedLinkString
.getStringAnnotations(TAG_URL, it, it)
.firstOrNull()?.let {
onUnsubscribeClick()
}
},
)
}
}

View file

@ -8,7 +8,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@ -151,19 +153,100 @@ fun createAnnotatedString(
end = mainString.length,
)
for (highlightString in highlights) {
val startIndexUnsubscribe = mainString.indexOf(highlightString, ignoreCase = true)
val endIndexUnsubscribe = startIndexUnsubscribe + highlightString.length
val startIndex = mainString.indexOf(highlightString, ignoreCase = true)
val endIndex = startIndex + highlightString.length
addStyle(
style = highlightStyle,
start = startIndexUnsubscribe,
end = endIndexUnsubscribe,
start = startIndex,
end = endIndex,
)
addStringAnnotation(
tag = tag,
annotation = highlightString,
start = startIndexUnsubscribe,
end = endIndexUnsubscribe,
start = startIndex,
end = endIndex,
)
}
}
}
/**
* Create an [AnnotatedString] with highlighted parts that can be clicked.
* @param mainString the full string to be processed.
* @param highlights list of [ClickableTextHighlight]s to be annotated within the [mainString].
* If a highlighted text is repeated in the [mainString], you must choose which instance to use
* by setting the [ClickableTextHighlight.instance] property. Only one instance of the text will
* be annotated.
*/
@Composable
fun createClickableAnnotatedString(
mainString: String,
highlights: List<ClickableTextHighlight>,
style: SpanStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
),
highlightStyle: SpanStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.interaction,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
): AnnotatedString {
return buildAnnotatedString {
append(mainString)
addStyle(
style = style,
start = 0,
end = mainString.length,
)
for (highlight in highlights) {
val text = highlight.textToHighlight
val startIndex = when (highlight.instance) {
ClickableTextHighlight.Instance.FIRST -> {
mainString.indexOf(text, ignoreCase = true)
}
ClickableTextHighlight.Instance.LAST -> {
mainString.lastIndexOf(text, ignoreCase = true)
}
}
val endIndex = startIndex + highlight.textToHighlight.length
val link = LinkAnnotation.Clickable(
tag = highlight.textToHighlight,
styles = TextLinkStyles(
style = highlightStyle,
),
) {
highlight.onTextClick.invoke()
}
addLink(
link,
start = startIndex,
end = endIndex,
)
}
}
}
/**
* Models text that should be highlighted with and associated with a click action.
* @property textToHighlight the text to highlight and associate with click action.
* @property onTextClick the click action to perform when the text is clicked.
* @property instance to denote if there are multiple instances of the [textToHighlight] in the
* [AnnotatedString] which should be highlighted.
*/
data class ClickableTextHighlight(
val textToHighlight: String,
val onTextClick: () -> Unit,
val instance: Instance = Instance.FIRST,
) {
/**
* To denote if a [ClickableTextHighlight.textToHighlight] should highlight the
* first instance of the text or the last instance.
* "If you ain't first, you're last" == true
*/
enum class Instance {
FIRST,
LAST,
}
}

View file

@ -1,14 +1,15 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.printToLog
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertLinkAnnotationIsAppliedAndInvokeClickAction
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -90,10 +91,17 @@ class CheckEmailScreenTest : BaseComposeTest() {
@Test
fun `go back and update email text click should send ChangeEmailClick action`() {
mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false)
composeTestRule
.onNodeWithText("No email? Go back to edit your email address.")
.performScrollTo()
.performSemanticsAction(SemanticsActions.OnClick)
composeTestRule.onRoot().printToLog("oh shit")
val mainString = "No email? Go back to edit your email address."
val linkText = "Go back"
val expectedStart = mainString.indexOf(linkText)
val expectedEnd = expectedStart + linkText.length
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString = mainString,
highLightText = linkText,
expectedStart = expectedStart,
expectedEnd = expectedEnd,
)
verify { viewModel.trySendAction(CheckEmailAction.ChangeEmailClick) }
}
@ -101,10 +109,16 @@ class CheckEmailScreenTest : BaseComposeTest() {
@Test
fun `already have account text click should send ChangeEmailClick action`() {
mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false)
composeTestRule
.onNodeWithText("Or log in, you may already have an account.")
.performScrollTo()
.performSemanticsAction(SemanticsActions.OnClick)
val mainString = "Or log in, you may already have an account."
val linkText = "log in"
val expectedStart = mainString.indexOf(linkText)
val expectedEnd = expectedStart + linkText.length
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString = mainString,
highLightText = linkText,
expectedStart = expectedStart,
expectedEnd = expectedEnd,
)
verify { viewModel.trySendAction(CheckEmailAction.LoginClick) }
}

View file

@ -0,0 +1,136 @@
package com.x8bit.bitwarden.ui.platform.base.util
import androidx.compose.material3.Text
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.util.assertLinkAnnotationIsAppliedAndInvokeClickAction
import org.junit.Assert.assertTrue
import org.junit.Test
class ClickableAnnotatedStringTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `clickable annotated string should add Clickable LinkAnnotation to highlighted string`() {
var textClickCalled = false
val mainString = "This is me testing the thing."
val highLightText = "testing"
composeTestRule.setContent {
val annotatedString = createClickableAnnotatedString(
mainString,
listOf(
ClickableTextHighlight(
textToHighlight = highLightText,
onTextClick = { textClickCalled = true },
),
),
)
Text(text = annotatedString)
}
val expectedStart = mainString.indexOf(highLightText)
val expectedEnd = expectedStart + highLightText.length
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString,
highLightText,
expectedStart,
expectedEnd,
)
assertTrue(textClickCalled)
}
@Suppress("MaxLineLength")
@Test
fun `clickable annotated string should add multiple Clickable LinkAnnotations to highlighted string`() {
val mainString = "This is me testing the thing."
val highLightText1 = "testing"
val highlightText2 = "thing"
composeTestRule.setContent {
val annotatedString = createClickableAnnotatedString(
mainString,
listOf(
ClickableTextHighlight(
textToHighlight = highLightText1,
onTextClick = {},
),
ClickableTextHighlight(
textToHighlight = highlightText2,
onTextClick = {},
),
),
)
Text(text = annotatedString)
}
val expectedStart1 = mainString.indexOf(highLightText1)
val expectedEnd1 = expectedStart1 + highLightText1.length
val expectedStart2 = mainString.indexOf(highlightText2)
val expectedEnd2 = expectedStart2 + highlightText2.length
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString,
highLightText1,
expectedStart1,
expectedEnd1,
)
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString,
highlightText2,
expectedStart2,
expectedEnd2,
)
}
@Suppress("MaxLineLength")
@Test
fun `clickable annotated string should add annotation to first instance of highlighted string`() {
val mainString = "Testing 1,2,3 testing"
val highLightText = "testing"
composeTestRule.setContent {
val annotatedString = createClickableAnnotatedString(
mainString,
listOf(
ClickableTextHighlight(
textToHighlight = highLightText,
onTextClick = {},
instance = ClickableTextHighlight.Instance.FIRST,
),
),
)
Text(text = annotatedString)
}
// indexOf returns the index of the first instance.
val expectedStart = mainString.indexOf(highLightText)
val expectedEnd = expectedStart + highLightText.length
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString,
highLightText,
expectedStart,
expectedEnd,
)
}
@Suppress("MaxLineLength")
@Test
fun `clickable annotated string should add annotation to last instance of highlighted string`() {
val mainString = "Testing 1,2,3 testing"
val highLightText = "testing"
composeTestRule.setContent {
val annotatedString = createClickableAnnotatedString(
mainString,
listOf(
ClickableTextHighlight(
textToHighlight = highLightText,
onTextClick = {},
instance = ClickableTextHighlight.Instance.FIRST,
),
),
)
Text(text = annotatedString)
}
// indexOf returns the index of the first instance.
val expectedStart = mainString.lastIndexOf(highLightText)
val expectedEnd = expectedStart + highLightText.length
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString,
highLightText,
expectedStart,
expectedEnd,
)
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
@ -21,6 +22,9 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.printToString
import androidx.compose.ui.text.LinkAnnotation
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.jupiter.api.assertThrows
/**
@ -173,3 +177,32 @@ fun SemanticsNodeInteraction.performCustomAccessibilityAction(label: String) {
)
}
}
/**
* Helper function to assert link annotation is applied to the given text in
* the [mainString] and invoke click action if it is found.
*/
fun ComposeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString: String,
highLightText: String,
expectedStart: Int,
expectedEnd: Int,
) {
this
.onNodeWithText(mainString)
.fetchSemanticsNode()
.config
.getOrNull(SemanticsProperties.Text)
?.let { text ->
text.forEach {
it.getLinkAnnotations(expectedStart, expectedEnd)
.forEach { annotationRange ->
val annotation = annotationRange.item as? LinkAnnotation.Clickable
val tag = annotation?.tag
assertNotNull(tag)
assertTrue(highLightText.equals(tag, ignoreCase = true))
annotation?.linkInteractionListener?.onClick(annotation)
}
}
}
}