From 4385583146fd9db90bd53243e17d79d1923efa93 Mon Sep 17 00:00:00 2001 From: francoisadam Date: Wed, 11 Dec 2024 15:29:24 +0100 Subject: [PATCH 1/3] Add accessibility spans to annotatedStringResource --- .../spark/res/AnnotatedStringResource.kt | 50 +++++++++++++++++-- .../spark/res/SparkAccessiblitySpan.kt | 39 +++++++++++++++ .../spark/res/SparkStringAnnotations.kt | 19 +++++++ 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessiblitySpan.kt diff --git a/spark/src/main/kotlin/com/adevinta/spark/res/AnnotatedStringResource.kt b/spark/src/main/kotlin/com/adevinta/spark/res/AnnotatedStringResource.kt index 1e611b4c9..f3be2f892 100644 --- a/spark/src/main/kotlin/com/adevinta/spark/res/AnnotatedStringResource.kt +++ b/spark/src/main/kotlin/com/adevinta/spark/res/AnnotatedStringResource.kt @@ -48,7 +48,9 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.VerbatimTtsAnnotation import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle @@ -66,6 +68,7 @@ import androidx.core.text.toHtml import com.adevinta.spark.PreviewTheme import com.adevinta.spark.R import com.adevinta.spark.SparkTheme +import com.adevinta.spark.res.SparkStringAnnotations.toAccessibilitySpan import com.adevinta.spark.tokens.SparkColors import com.adevinta.spark.tokens.SparkTypography import kotlinx.collections.immutable.PersistentMap @@ -291,16 +294,23 @@ private fun CharSequence.asAnnotatedString( ): AnnotatedString { if (this !is Spanned) return AnnotatedString(this.toString()) return buildAnnotatedString { - append(this@asAnnotatedString.toString()) + val annotatedString = this@asAnnotatedString.toString() + append(annotatedString) getSpans(0, length, Any::class.java).forEach { val start = getSpanStart(it) val end = getSpanEnd(it) - buildWithSpan(it, start, end, density, colors, typography) + buildWithSpanStyle(it, start, end, density, colors, typography) + + val textToFormat = annotatedString.substring(start, end) + buildWithAccessibilitySpan(it, textToFormat, start, end) } } } -private fun AnnotatedString.Builder.buildWithSpan( +/** + * Update the [AnnotatedString] from `start` to `end` with the provided [SpanStyle], [Density], [SparkColors] and [SparkTypography]. + */ +private fun AnnotatedString.Builder.buildWithSpanStyle( it: Any, start: Int, end: Int, @@ -332,6 +342,40 @@ private fun AnnotatedString.Builder.buildWithSpan( addStyle(span, start, end) } +/** + * Update the [AnnotatedString] format from `start` to `end` with the provided [SparkAccessiblitySpan]. + */ +@OptIn(ExperimentalTextApi::class) +private fun AnnotatedString.Builder.buildWithAccessibilitySpan( + it: Any, + textToFormat: String, + start: Int, + end: Int, +) { + if (it !is Annotation) return + when(it.value.toAccessibilitySpan()) { + SparkAccessiblitySpan.CARDINAL -> { /*TODO*/ } + SparkAccessiblitySpan.DATE -> { /*TODO*/ } + SparkAccessiblitySpan.DECIMAL -> { /*TODO*/ } + SparkAccessiblitySpan.DIGITS -> { /*TODO*/ } + SparkAccessiblitySpan.ELECTRONIC -> { /*TODO*/ } + SparkAccessiblitySpan.FRACTION -> { /*TODO*/ } + SparkAccessiblitySpan.MEASURE -> { /*TODO*/ } + SparkAccessiblitySpan.MONEY -> { /*TODO*/ } + SparkAccessiblitySpan.ORDINAL -> { /*TODO*/ } + SparkAccessiblitySpan.TELEPHONE -> { /*TODO*/ } + SparkAccessiblitySpan.TEXT -> { /*TODO*/ } + SparkAccessiblitySpan.TIME -> { /*TODO*/ } + SparkAccessiblitySpan.VERBATIM -> { + addTtsAnnotation( + ttsAnnotation = VerbatimTtsAnnotation(textToFormat), + start = start, + end = end, + ) + } + } +} + private fun StyleSpan.toSpanStyle(): SpanStyle? = when (style) { Typeface.NORMAL -> FontWeight.Normal to FontStyle.Normal Typeface.BOLD -> FontWeight.Bold to FontStyle.Normal diff --git a/spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessiblitySpan.kt b/spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessiblitySpan.kt new file mode 100644 index 000000000..20bc0dde6 --- /dev/null +++ b/spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessiblitySpan.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024 Adevinta + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package com.adevinta.spark.res + +public enum class SparkAccessiblitySpan(public val annotation: String) { + CARDINAL("cardinal"), + DATE("date"), + DECIMAL("decimal"), + DIGITS("digits"), + ELECTRONIC("electronic"), + FRACTION("fraction"), + MEASURE("measure"), + MONEY("money"), + ORDINAL("ordinal"), + TELEPHONE("telephone"), + TEXT("text"), + TIME("time"), + VERBATIM("verbatim"), +} \ No newline at end of file diff --git a/spark/src/main/kotlin/com/adevinta/spark/res/SparkStringAnnotations.kt b/spark/src/main/kotlin/com/adevinta/spark/res/SparkStringAnnotations.kt index 6abae8021..0712653a1 100644 --- a/spark/src/main/kotlin/com/adevinta/spark/res/SparkStringAnnotations.kt +++ b/spark/src/main/kotlin/com/adevinta/spark/res/SparkStringAnnotations.kt @@ -84,4 +84,23 @@ public object SparkStringAnnotations { Log.d("StringResources", "Spark typography annotation : $this is not supported") } }?.toSpanStyle() + + /** + * Given a string representing an annotation value of a accessibility span, returns the corresponding [SparkAccessiblitySpan]. + */ + public fun String.toAccessibilitySpan(): SparkAccessiblitySpan = when (this.lowercase()) { + SparkAccessiblitySpan.CARDINAL.annotation -> SparkAccessiblitySpan.CARDINAL + SparkAccessiblitySpan.DATE.annotation -> SparkAccessiblitySpan.DATE + SparkAccessiblitySpan.DECIMAL.annotation -> SparkAccessiblitySpan.DECIMAL + SparkAccessiblitySpan.DIGITS.annotation -> SparkAccessiblitySpan.DIGITS + SparkAccessiblitySpan.ELECTRONIC.annotation -> SparkAccessiblitySpan.ELECTRONIC + SparkAccessiblitySpan.FRACTION.annotation -> SparkAccessiblitySpan.FRACTION + SparkAccessiblitySpan.MEASURE.annotation -> SparkAccessiblitySpan.MEASURE + SparkAccessiblitySpan.MONEY.annotation -> SparkAccessiblitySpan.MONEY + SparkAccessiblitySpan.ORDINAL.annotation -> SparkAccessiblitySpan.ORDINAL + SparkAccessiblitySpan.TELEPHONE.annotation -> SparkAccessiblitySpan.TELEPHONE + SparkAccessiblitySpan.TIME.annotation -> SparkAccessiblitySpan.TIME + SparkAccessiblitySpan.VERBATIM.annotation -> SparkAccessiblitySpan.VERBATIM + else -> SparkAccessiblitySpan.TEXT + } } From 378d9247ead444afd17111ad1cf44679b3c07f6e Mon Sep 17 00:00:00 2001 From: francoisadam Date: Wed, 11 Dec 2024 17:02:27 +0100 Subject: [PATCH 2/3] Add catalog app tab for accessibility --- .../com/adevinta/spark/catalog/CatalogApp.kt | 7 +- .../accessibility/AccessibilityDemoScreen.kt | 50 +++++ .../accessibility/AccessibilityScreen.kt | 185 ++++++++++++++++++ catalog/src/main/res/values/strings.xml | 44 +++++ 4 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 catalog/src/main/kotlin/com/adevinta/spark/catalog/accessibility/AccessibilityDemoScreen.kt create mode 100644 catalog/src/main/kotlin/com/adevinta/spark/catalog/accessibility/AccessibilityScreen.kt diff --git a/catalog/src/main/kotlin/com/adevinta/spark/catalog/CatalogApp.kt b/catalog/src/main/kotlin/com/adevinta/spark/catalog/CatalogApp.kt index 4db450fd6..264045c8c 100644 --- a/catalog/src/main/kotlin/com/adevinta/spark/catalog/CatalogApp.kt +++ b/catalog/src/main/kotlin/com/adevinta/spark/catalog/CatalogApp.kt @@ -68,6 +68,7 @@ import com.adevinta.spark.SparkFeatureFlag import com.adevinta.spark.SparkTheme import com.adevinta.spark.catalog.configurator.ConfiguratorComponentsScreen import com.adevinta.spark.catalog.examples.ComponentsScreen +import com.adevinta.spark.catalog.accessibility.AccessibilityDemoScreen import com.adevinta.spark.catalog.icons.IconDemoScreen import com.adevinta.spark.catalog.model.Component import com.adevinta.spark.catalog.tabbar.CatalogTabBar @@ -269,6 +270,10 @@ internal fun ComponentActivity.CatalogApp( CatalogHomeScreen.Icons -> IconDemoScreen( contentPadding = innerPadding, ) + + CatalogHomeScreen.Accessibility -> AccessibilityDemoScreen( + contentPadding = innerPadding, + ) } } }, @@ -304,7 +309,7 @@ private fun HomeTabBar( } } -public enum class CatalogHomeScreen { Examples, Configurator, Icons } +public enum class CatalogHomeScreen { Examples, Configurator, Icons, Accessibility } /** * The default light scrim, as defined by androidx and the platform: diff --git a/catalog/src/main/kotlin/com/adevinta/spark/catalog/accessibility/AccessibilityDemoScreen.kt b/catalog/src/main/kotlin/com/adevinta/spark/catalog/accessibility/AccessibilityDemoScreen.kt new file mode 100644 index 000000000..f98e80800 --- /dev/null +++ b/catalog/src/main/kotlin/com/adevinta/spark/catalog/accessibility/AccessibilityDemoScreen.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 Adevinta + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.adevinta.spark.catalog.accessibility + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController + +@Composable +public fun AccessibilityDemoScreen( + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + val navController = rememberNavController() + + NavHost( + modifier = modifier, + navController = navController, + startDestination = Accessibility, + builder = { + composable(route = Accessibility) { + AccessibilityScreen(contentPadding) + } + }, + ) +} + +internal const val Accessibility = "accessibility" diff --git a/catalog/src/main/kotlin/com/adevinta/spark/catalog/accessibility/AccessibilityScreen.kt b/catalog/src/main/kotlin/com/adevinta/spark/catalog/accessibility/AccessibilityScreen.kt new file mode 100644 index 000000000..89dbee63d --- /dev/null +++ b/catalog/src/main/kotlin/com/adevinta/spark/catalog/accessibility/AccessibilityScreen.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2024 Adevinta + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.adevinta.spark.catalog.accessibility + +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.adevinta.spark.SparkTheme +import com.adevinta.spark.catalog.R +import com.adevinta.spark.components.buttons.ButtonOutlined +import com.adevinta.spark.components.divider.DividerIntent +import com.adevinta.spark.components.divider.HorizontalDivider +import com.adevinta.spark.components.spacer.VerticalSpacer +import com.adevinta.spark.components.text.Text +import com.adevinta.spark.icons.SparkIcons +import com.adevinta.spark.icons.WheelOutline +import com.adevinta.spark.res.annotatedStringResource +import com.adevinta.spark.tokens.highlight + +@Composable +public fun AccessibilityScreen( + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val accessibilityTypes: List by remember { + mutableStateOf(getAllAccessibilityRes()) + } + Column( + modifier = modifier.fillMaxSize(), + ) { + ButtonOutlined( + icon = SparkIcons.WheelOutline, + onClick = { + context.startActivity(Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)) + }, + modifier = Modifier.padding(16.dp), + ) { + Text(text = stringResource(id = R.string.accessibility_settings_shortcut)) + } + + LazyColumn( + modifier = modifier + .consumeWindowInsets(contentPadding) + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(accessibilityTypes.size) { index -> + val accessibilityType = accessibilityTypes[index] + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(id = R.string.accessibility_settings_type_label)) + Text( + text = stringResource(id = accessibilityType.type), + style = SparkTheme.typography.body1.highlight, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(id = R.string.accessibility_settings_input_label)) + Text( + text = annotatedStringResource(id = accessibilityType.input), + color = SparkTheme.colors.support, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(id = R.string.accessibility_settings_result_label)) + Text( + text = annotatedStringResource(id = accessibilityType.result), + color = SparkTheme.colors.accent, + ) + } + VerticalSpacer(8.dp) + HorizontalDivider(intent = DividerIntent.Outline) + } + } + } +} + +private data class AccessibilityType( + val type: Int, + val input: Int, + val result: Int, +) + +private fun getAllAccessibilityRes() = listOf( + AccessibilityType( + type = R.string.accessibility_settings_cardinal_type, + input = R.string.accessibility_settings_cardinal_input, + result = R.string.accessibility_settings_cardinal_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_date_type, + input = R.string.accessibility_settings_date_input, + result = R.string.accessibility_settings_date_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_decimal_type, + input = R.string.accessibility_settings_decimal_input, + result = R.string.accessibility_settings_decimal_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_digits_type, + input = R.string.accessibility_settings_digits_input, + result = R.string.accessibility_settings_digits_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_electronic_type, + input = R.string.accessibility_settings_electronic_input, + result = R.string.accessibility_settings_electronic_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_fraction_type, + input = R.string.accessibility_settings_fraction_input, + result = R.string.accessibility_settings_fraction_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_measure_type, + input = R.string.accessibility_settings_measure_input, + result = R.string.accessibility_settings_measure_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_money_type, + input = R.string.accessibility_settings_money_input, + result = R.string.accessibility_settings_money_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_ordinal_type, + input = R.string.accessibility_settings_ordinal_input, + result = R.string.accessibility_settings_ordinal_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_telephone_type, + input = R.string.accessibility_settings_telephone_input, + result = R.string.accessibility_settings_telephone_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_text_type, + input = R.string.accessibility_settings_text_input, + result = R.string.accessibility_settings_text_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_time_type, + input = R.string.accessibility_settings_time_input, + result = R.string.accessibility_settings_time_result, + ), + AccessibilityType( + type = R.string.accessibility_settings_verbatim_type, + input = R.string.accessibility_settings_verbatim_input, + result = R.string.accessibility_settings_verbatim_result, + ), +) \ No newline at end of file diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml index 7a2d72f16..7d1e297a4 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -122,6 +122,50 @@ Search Spark Icon + Accessibility settings + Accessibility Span Type: + Input: + Result: + cardinal + There are 3 apples + There are 3 apples + date + 11/12/2024 + 11/12/2024 + decimal + 18,12 + 18,12 + digits + 1234567890 + 1234567890 + electronic + protocol://username:password@domain:port/path?query_string#fragment_id + protocol://username:password@domain:port/path?query_string#fragment_id + fraction + 18/12 + 18/12 + measure + 18m + 18m + money + 1 000 € + 1 000 € + ordinal + the 2nd best + the 2nd best + telephone + +33612345678 + +33612345678 + text + This is a pretty text + This is a pretty text + time + 18h 12min 30s + 18h 12min 30s + verbatim + L.G.T.M + L.G.T.M + This is Adevinta Privacy & Policy also lots of extra information you maybe interested in or should I inform you about that extra information. you might be not knowing it Learn Kotlin Programming https://kotlinlang.org From e1a544f3d805a26d4968521d9eeb867302e9ee97 Mon Sep 17 00:00:00 2001 From: francoisadam Date: Thu, 12 Dec 2024 16:56:42 +0100 Subject: [PATCH 3/3] Implement all accessibility span formatters --- catalog/src/main/res/values/strings.xml | 8 +- spark/build.gradle.kts | 4 + .../spark/res/AnnotatedStringResource.kt | 51 ++----- .../spark/res/SparkAccessibilitySpan.kt | 127 ++++++++++++++++++ .../spark/res/SparkAccessiblitySpan.kt | 39 ------ .../spark/res/SparkStringAnnotations.kt | 30 ++--- 6 files changed, 161 insertions(+), 98 deletions(-) create mode 100644 spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessibilitySpan.kt delete mode 100644 spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessiblitySpan.kt diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml index 7d1e297a4..2c584f401 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -145,8 +145,8 @@ 18/12 18/12 measure - 18m - 18m + 18kg 28L et 250km/h + 18kg 28L et 250km/h money 1 000 € 1 000 € @@ -160,8 +160,8 @@ This is a pretty text This is a pretty text time - 18h 12min 30s - 18h 12min 30s + 18:12 + 18:12 verbatim L.G.T.M L.G.T.M diff --git a/spark/build.gradle.kts b/spark/build.gradle.kts index 80e044c21..42786a250 100644 --- a/spark/build.gradle.kts +++ b/spark/build.gradle.kts @@ -35,6 +35,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + compileOptions.isCoreLibraryDesugaringEnabled = true + testOptions { unitTests.isIncludeAndroidResources = true unitTests.isReturnDefaultValues = true @@ -91,4 +93,6 @@ dependencies { androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.compose.ui.test) androidTestImplementation(libs.androidx.compose.ui.testManifest) + + coreLibraryDesugaring(libs.desugarJdkLibs) } diff --git a/spark/src/main/kotlin/com/adevinta/spark/res/AnnotatedStringResource.kt b/spark/src/main/kotlin/com/adevinta/spark/res/AnnotatedStringResource.kt index f3be2f892..db424a368 100644 --- a/spark/src/main/kotlin/com/adevinta/spark/res/AnnotatedStringResource.kt +++ b/spark/src/main/kotlin/com/adevinta/spark/res/AnnotatedStringResource.kt @@ -23,6 +23,7 @@ package com.adevinta.spark.res import android.content.res.Resources import android.graphics.Typeface +import android.telephony.PhoneNumberUtils import android.text.Annotation import android.text.Spanned import android.text.SpannedString @@ -48,9 +49,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.VerbatimTtsAnnotation import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle @@ -73,6 +72,10 @@ import com.adevinta.spark.tokens.SparkColors import com.adevinta.spark.tokens.SparkTypography import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf +import java.text.DecimalFormatSymbols +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale /** * Load an annotated string resource with formatting. @@ -294,15 +297,17 @@ private fun CharSequence.asAnnotatedString( ): AnnotatedString { if (this !is Spanned) return AnnotatedString(this.toString()) return buildAnnotatedString { - val annotatedString = this@asAnnotatedString.toString() + var annotatedString = this@asAnnotatedString + getSpans(0, length, Any::class.java).forEach { + val start = getSpanStart(it) + val end = getSpanEnd(it) + annotatedString = annotatedString.buildWithAccessibilitySpan(it, start, end) + } append(annotatedString) getSpans(0, length, Any::class.java).forEach { val start = getSpanStart(it) val end = getSpanEnd(it) buildWithSpanStyle(it, start, end, density, colors, typography) - - val textToFormat = annotatedString.substring(start, end) - buildWithAccessibilitySpan(it, textToFormat, start, end) } } } @@ -342,40 +347,6 @@ private fun AnnotatedString.Builder.buildWithSpanStyle( addStyle(span, start, end) } -/** - * Update the [AnnotatedString] format from `start` to `end` with the provided [SparkAccessiblitySpan]. - */ -@OptIn(ExperimentalTextApi::class) -private fun AnnotatedString.Builder.buildWithAccessibilitySpan( - it: Any, - textToFormat: String, - start: Int, - end: Int, -) { - if (it !is Annotation) return - when(it.value.toAccessibilitySpan()) { - SparkAccessiblitySpan.CARDINAL -> { /*TODO*/ } - SparkAccessiblitySpan.DATE -> { /*TODO*/ } - SparkAccessiblitySpan.DECIMAL -> { /*TODO*/ } - SparkAccessiblitySpan.DIGITS -> { /*TODO*/ } - SparkAccessiblitySpan.ELECTRONIC -> { /*TODO*/ } - SparkAccessiblitySpan.FRACTION -> { /*TODO*/ } - SparkAccessiblitySpan.MEASURE -> { /*TODO*/ } - SparkAccessiblitySpan.MONEY -> { /*TODO*/ } - SparkAccessiblitySpan.ORDINAL -> { /*TODO*/ } - SparkAccessiblitySpan.TELEPHONE -> { /*TODO*/ } - SparkAccessiblitySpan.TEXT -> { /*TODO*/ } - SparkAccessiblitySpan.TIME -> { /*TODO*/ } - SparkAccessiblitySpan.VERBATIM -> { - addTtsAnnotation( - ttsAnnotation = VerbatimTtsAnnotation(textToFormat), - start = start, - end = end, - ) - } - } -} - private fun StyleSpan.toSpanStyle(): SpanStyle? = when (style) { Typeface.NORMAL -> FontWeight.Normal to FontStyle.Normal Typeface.BOLD -> FontWeight.Bold to FontStyle.Normal diff --git a/spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessibilitySpan.kt b/spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessibilitySpan.kt new file mode 100644 index 000000000..2dcfddb0a --- /dev/null +++ b/spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessibilitySpan.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2024 Adevinta + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package com.adevinta.spark.res + +import android.telephony.PhoneNumberUtils +import android.text.Annotation +import com.adevinta.spark.res.SparkStringAnnotations.toAccessibilitySpan +import java.text.DecimalFormatSymbols +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +public enum class SparkAccessibilitySpan(public val annotation: String) { + CARDINAL("cardinal"), + DATE("date"), + DECIMAL("decimal"), + DIGITS("digits"), + ELECTRONIC("electronic"), + FRACTION("fraction"), + MEASURE("measure"), + MONEY("money"), + ORDINAL("ordinal"), + TELEPHONE("telephone"), + TEXT("text"), + TIME("time"), + VERBATIM("verbatim"), +} + +/** + * Update the [String] format from `start` to `end` with the provided [SparkAccessibilitySpan]. + */ +internal fun CharSequence.buildWithAccessibilitySpan( + it: Any, + start: Int, + end: Int, +): String { + if (it !is Annotation) return this.toString() + val baseSubstring = substring(start, end) + return this.toString().replaceRange( + startIndex = start, + endIndex = end, + replacement = when (it.value.toAccessibilitySpan()) { + SparkAccessibilitySpan.CARDINAL -> baseSubstring + SparkAccessibilitySpan.DATE -> baseSubstring.applyDateSpan() + SparkAccessibilitySpan.DECIMAL -> baseSubstring.applyDecimalSpan() + SparkAccessibilitySpan.DIGITS -> baseSubstring.applyDigitsSpan() + SparkAccessibilitySpan.ELECTRONIC -> baseSubstring.applyElectronicSpan() + SparkAccessibilitySpan.FRACTION -> baseSubstring.applyFractionSpan() + SparkAccessibilitySpan.MEASURE -> baseSubstring.applyMeasureSpan() + SparkAccessibilitySpan.MONEY -> baseSubstring.applyMoneySpan() + SparkAccessibilitySpan.ORDINAL -> baseSubstring + SparkAccessibilitySpan.TELEPHONE -> baseSubstring.applyTelephoneSpan() + SparkAccessibilitySpan.TEXT -> baseSubstring + SparkAccessibilitySpan.TIME -> baseSubstring.applyTimeSpan() + SparkAccessibilitySpan.VERBATIM -> baseSubstring.applyVerbatimSpan() + }, + ) +} + +private fun String.applyElectronicSpan() = replace(":", " deux points ") + .replace(".", " point ") + .replace("/", " slash ") + .replace("@", " at ") + +private fun String.applyDateSpan() = replace(" ", "/") + .replace("-", "/") + .let { date -> + runCatching { + val inputDate = LocalDate.parse( + date, + DateTimeFormatter.ofPattern("d/M/y", Locale.getDefault()), + ) + DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.getDefault()).format(inputDate) + }.getOrDefault(date) + } + +private fun String.applyDecimalSpan() = replace( + DecimalFormatSymbols(Locale.getDefault()).decimalSeparator.toString(), + " virgule ", +) + +private fun String.applyDigitsSpan() = applyVerbatimSpan() +private fun String.applyFractionSpan() = replace("/", " sur ") +private fun String.applyMeasureSpan() = replace("l", "litres", ignoreCase = true) + .replace("k", "kilo") + .replace("m", "mètres", ignoreCase = true) + .replace("g", "gramme") + .replace("c", "centi") + .replace("h", "heure") + .replace("/", " par ") + +private fun String.applyMoneySpan() = this.replace(" ", "").replace(" ", "") + +private fun String.applyTelephoneSpan() = + PhoneNumberUtils.formatNumber(this, Locale.getDefault().country) + +private fun String.applyTimeSpan() = this.replace(" ", ":") + .replace("-", ":") + .let { date -> + runCatching { + val inputDate = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()).parse(date) + DateTimeFormatter.ofPattern("HH' heures 'mm' minutes'", Locale.getDefault()) + .format(inputDate) + }.getOrDefault(date) + } + +private fun String.applyVerbatimSpan() = map { char -> "$char " }.joinToString("") \ No newline at end of file diff --git a/spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessiblitySpan.kt b/spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessiblitySpan.kt deleted file mode 100644 index 20bc0dde6..000000000 --- a/spark/src/main/kotlin/com/adevinta/spark/res/SparkAccessiblitySpan.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2024 Adevinta - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package com.adevinta.spark.res - -public enum class SparkAccessiblitySpan(public val annotation: String) { - CARDINAL("cardinal"), - DATE("date"), - DECIMAL("decimal"), - DIGITS("digits"), - ELECTRONIC("electronic"), - FRACTION("fraction"), - MEASURE("measure"), - MONEY("money"), - ORDINAL("ordinal"), - TELEPHONE("telephone"), - TEXT("text"), - TIME("time"), - VERBATIM("verbatim"), -} \ No newline at end of file diff --git a/spark/src/main/kotlin/com/adevinta/spark/res/SparkStringAnnotations.kt b/spark/src/main/kotlin/com/adevinta/spark/res/SparkStringAnnotations.kt index 0712653a1..45ae91b9a 100644 --- a/spark/src/main/kotlin/com/adevinta/spark/res/SparkStringAnnotations.kt +++ b/spark/src/main/kotlin/com/adevinta/spark/res/SparkStringAnnotations.kt @@ -86,21 +86,21 @@ public object SparkStringAnnotations { }?.toSpanStyle() /** - * Given a string representing an annotation value of a accessibility span, returns the corresponding [SparkAccessiblitySpan]. + * Given a string representing an annotation value of a accessibility span, returns the corresponding [SparkAccessibilitySpan]. */ - public fun String.toAccessibilitySpan(): SparkAccessiblitySpan = when (this.lowercase()) { - SparkAccessiblitySpan.CARDINAL.annotation -> SparkAccessiblitySpan.CARDINAL - SparkAccessiblitySpan.DATE.annotation -> SparkAccessiblitySpan.DATE - SparkAccessiblitySpan.DECIMAL.annotation -> SparkAccessiblitySpan.DECIMAL - SparkAccessiblitySpan.DIGITS.annotation -> SparkAccessiblitySpan.DIGITS - SparkAccessiblitySpan.ELECTRONIC.annotation -> SparkAccessiblitySpan.ELECTRONIC - SparkAccessiblitySpan.FRACTION.annotation -> SparkAccessiblitySpan.FRACTION - SparkAccessiblitySpan.MEASURE.annotation -> SparkAccessiblitySpan.MEASURE - SparkAccessiblitySpan.MONEY.annotation -> SparkAccessiblitySpan.MONEY - SparkAccessiblitySpan.ORDINAL.annotation -> SparkAccessiblitySpan.ORDINAL - SparkAccessiblitySpan.TELEPHONE.annotation -> SparkAccessiblitySpan.TELEPHONE - SparkAccessiblitySpan.TIME.annotation -> SparkAccessiblitySpan.TIME - SparkAccessiblitySpan.VERBATIM.annotation -> SparkAccessiblitySpan.VERBATIM - else -> SparkAccessiblitySpan.TEXT + public fun String.toAccessibilitySpan(): SparkAccessibilitySpan = when (this.lowercase()) { + SparkAccessibilitySpan.CARDINAL.annotation -> SparkAccessibilitySpan.CARDINAL + SparkAccessibilitySpan.DATE.annotation -> SparkAccessibilitySpan.DATE + SparkAccessibilitySpan.DECIMAL.annotation -> SparkAccessibilitySpan.DECIMAL + SparkAccessibilitySpan.DIGITS.annotation -> SparkAccessibilitySpan.DIGITS + SparkAccessibilitySpan.ELECTRONIC.annotation -> SparkAccessibilitySpan.ELECTRONIC + SparkAccessibilitySpan.FRACTION.annotation -> SparkAccessibilitySpan.FRACTION + SparkAccessibilitySpan.MEASURE.annotation -> SparkAccessibilitySpan.MEASURE + SparkAccessibilitySpan.MONEY.annotation -> SparkAccessibilitySpan.MONEY + SparkAccessibilitySpan.ORDINAL.annotation -> SparkAccessibilitySpan.ORDINAL + SparkAccessibilitySpan.TELEPHONE.annotation -> SparkAccessibilitySpan.TELEPHONE + SparkAccessibilitySpan.TIME.annotation -> SparkAccessibilitySpan.TIME + SparkAccessibilitySpan.VERBATIM.annotation -> SparkAccessibilitySpan.VERBATIM + else -> SparkAccessibilitySpan.TEXT } }