Browse Source

feat: Add QuantityStringResource and pluralStringResource

pull/4587/head^2
Chanjung Kim 2 months ago
parent
commit
1a061929b8
  1. 4
      components/resources/demo/shared/src/commonMain/composeResources/values/strings.xml
  2. 26
      components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/StringRes.kt
  3. 155
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt
  4. 108
      components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt
  5. 7
      components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestUtils.kt
  6. 12
      components/resources/library/src/commonTest/resources/strings.xml
  7. 12
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/GenerateResClassTask.kt
  8. 4
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt

4
components/resources/demo/shared/src/commonMain/composeResources/values/strings.xml

@ -9,4 +9,8 @@ Donec eget turpis ac sem ultricies consequat.</string>
<item>item \u2318</item>
<item>item \u00BD</item>
</string-array>
<plurals name="new_message">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
</plurals>
</resources>

26
components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/StringRes.kt

@ -4,14 +4,14 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import components.resources.demo.shared.generated.resources.Res
import components.resources.demo.shared.generated.resources.*
import org.jetbrains.compose.resources.stringArrayResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.*
@Composable
fun StringRes(paddingValues: PaddingValues) {
@ -99,5 +99,25 @@ fun StringRes(paddingValues: PaddingValues) {
disabledLabelColor = MaterialTheme.colorScheme.onSurface,
)
)
var numMessages by remember { mutableStateOf(0) }
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = pluralStringResource(Res.plurals.new_message, numMessages, numMessages),
onValueChange = {},
label = { Text("Text(pluralStringResource(Res.plurals.new_message, $numMessages, $numMessages))") },
leadingIcon = {
Row {
IconButton({ numMessages += 1 }) {
Icon(Icons.Default.Add, contentDescription = "Add Message")
}
}
},
enabled = false,
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = MaterialTheme.colorScheme.surface,
disabledLabelColor = MaterialTheme.colorScheme.onSurface,
)
)
}
}

155
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt

@ -9,6 +9,10 @@ import org.jetbrains.compose.resources.vector.xmldom.NodeList
private val SimpleStringFormatRegex = Regex("""%(\d)\$[ds]""")
private fun String.replaceWithArgs(args: List<String>) = SimpleStringFormatRegex.replace(this) { matchResult ->
args[matchResult.groupValues[1].toInt() - 1]
}
/**
* Represents a string resource in the application.
*
@ -22,8 +26,31 @@ private val SimpleStringFormatRegex = Regex("""%(\d)\$[ds]""")
class StringResource
@InternalResourceApi constructor(id: String, val key: String, items: Set<ResourceItem>) : Resource(id, items)
/**
* Represents a quantity string resource in the application.
*
* @param id The unique identifier of the resource.
* @param key The key used to retrieve the string resource.
* @param items The set of resource items associated with the string resource.
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
@Immutable
class QuantityStringResource
@InternalResourceApi constructor(id: String, val key: String, items: Set<ResourceItem>) : Resource(id, items)
internal enum class PluralCategory {
ZERO,
ONE,
TWO,
FEW,
MANY,
OTHER
}
private sealed interface StringItem {
data class Value(val text: String) : StringItem
data class Plurals(val items: Map<PluralCategory, String>) : StringItem
data class Array(val items: List<String>) : StringItem
}
@ -56,6 +83,15 @@ private suspend fun parseStringXml(path: String, resourceReader: ResourceReader)
val rawString = element.textContent.orEmpty()
element.getAttribute("name") to StringItem.Value(handleSpecialCharacters(rawString))
}
val plurals = nodes.getElementsWithName("plurals").associate { pluralElement ->
val items = pluralElement.childNodes.getElementsWithName("item").mapNotNull { element ->
val pluralCategory = PluralCategory.entries.firstOrNull {
it.name.equals(element.getAttribute("quantity"), true)
} ?: return@mapNotNull null
pluralCategory to element.textContent.orEmpty()
}
pluralElement.getAttribute("name") to StringItem.Plurals(items.toMap())
}
val arrays = nodes.getElementsWithName("string-array").associate { arrayElement ->
val items = arrayElement.childNodes.getElementsWithName("item").map { element ->
val rawString = element.textContent.orEmpty()
@ -63,7 +99,7 @@ private suspend fun parseStringXml(path: String, resourceReader: ResourceReader)
}
arrayElement.getAttribute("name") to StringItem.Array(items)
}
return strings + arrays
return strings + plurals + arrays
}
/**
@ -154,9 +190,108 @@ private suspend fun loadString(
environment: ResourceEnvironment
): String {
val str = loadString(resource, resourceReader, environment)
return SimpleStringFormatRegex.replace(str) { matchResult ->
args[matchResult.groupValues[1].toInt() - 1]
return str.replaceWithArgs(args)
}
/**
* Retrieves the string for the pluralization for the given quantity using the specified quantity string resource.
*
* @param resource The quantity string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @return The retrieved string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
@Composable
fun pluralStringResource(resource: QuantityStringResource, quantity: Int): String {
val resourceReader = LocalResourceReader.current
val pluralStr by rememberResourceState(resource, quantity, { "" }) { env ->
loadQuantityString(resource, quantity, resourceReader, env)
}
return pluralStr
}
/**
* Loads a string using the specified string resource.
*
* @param resource The string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @return The loaded string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
suspend fun getQuantityString(resource: QuantityStringResource, quantity: Int): String =
loadQuantityString(resource, quantity, DefaultResourceReader, getResourceEnvironment())
@OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
private suspend fun loadQuantityString(
resource: QuantityStringResource,
quantity: Int,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): String {
val path = resource.getPathByEnvironment(environment)
val keyToValue = getParsedStrings(path, resourceReader)
val item = keyToValue[resource.key] as? StringItem.Plurals
?: error("String ID=`${resource.key}` is not found!")
val pluralCategory = getPluralCategory(environment.language, quantity)
val str = item.items[pluralCategory]
?: error("String ID=`${resource.key}` does not have the pluralization $pluralCategory for quantity $quantity!")
return str
}
/**
* Retrieves the string for the pluralization for the given quantity using the specified quantity string resource.
*
* @param resource The quantity string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @param formatArgs The arguments to be inserted into the formatted string.
* @return The retrieved string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
@Composable
fun pluralStringResource(resource: QuantityStringResource, quantity: Int, vararg formatArgs: Any): String {
val resourceReader = LocalResourceReader.current
val args = formatArgs.map { it.toString() }
val quantityStr by rememberResourceState(resource, quantity, args, { "" }) { env ->
loadQuantityString(resource, quantity, args, resourceReader, env)
}
return quantityStr
}
/**
* Loads a string using the specified string resource.
*
* @param resource The string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @param formatArgs The arguments to be inserted into the formatted string.
* @return The loaded string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
suspend fun getQuantityString(resource: QuantityStringResource, quantity: Int, vararg formatArgs: Any): String =
loadQuantityString(
resource, quantity,
formatArgs.map { it.toString() },
DefaultResourceReader,
getResourceEnvironment(),
)
@OptIn(ExperimentalResourceApi::class)
private suspend fun loadQuantityString(
resource: QuantityStringResource,
quantity: Int,
args: List<String>,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): String {
val str = loadQuantityString(resource, quantity, resourceReader, environment)
return str.replaceWithArgs(args)
}
/**
@ -235,4 +370,16 @@ internal fun handleSpecialCharacters(string: String): String {
}
}.replace("""\\""", """\""")
return handledString
}
}
/**
* @param languageQualifier
* @param quantity
*/
@OptIn(InternalResourceApi::class)
internal fun getPluralCategory(languageQualifier: LanguageQualifier, quantity: Int): PluralCategory {
return when {
quantity == 1 -> PluralCategory.ONE
else -> PluralCategory.OTHER
}
}

108
components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt

@ -144,6 +144,102 @@ class ComposeResourceTest {
assertEquals(listOf("item 1", "item 2", "item 3"), getStringArray(TestStringResource("str_arr")))
}
@Test
fun testQuantityStringResourceCache() = runComposeUiTest {
val testResourceReader = TestResourceReader()
var res by mutableStateOf(TestQuantityStringResource("plurals"))
var quantity by mutableStateOf(0)
var str = ""
setContent {
CompositionLocalProvider(
LocalResourceReader provides testResourceReader,
LocalComposeEnvironment provides TestComposeEnvironment
) {
str = pluralStringResource(res, quantity)
}
}
waitForIdle()
assertEquals("other", str)
quantity = 1
waitForIdle()
assertEquals("one", str)
assertEquals(1, quantity)
quantity = 2
waitForIdle()
assertEquals("other", str)
assertEquals(2, quantity)
quantity = 3
waitForIdle()
assertEquals("other", str)
assertEquals(3, quantity)
res = TestQuantityStringResource("another_plurals")
quantity = 0
waitForIdle()
assertEquals("another other", str)
quantity = 1
waitForIdle()
assertEquals("another one", str)
}
@Test
fun testReadQuantityStringResource() = runComposeUiTest {
var plurals = ""
var another_plurals = ""
setContent {
CompositionLocalProvider(LocalComposeEnvironment provides TestComposeEnvironment) {
plurals = pluralStringResource(TestQuantityStringResource("plurals"), 1)
another_plurals = pluralStringResource(TestQuantityStringResource("another_plurals"), 1)
}
}
waitForIdle()
assertEquals("one", plurals)
assertEquals("another one", another_plurals)
}
@Test
fun testReadQualityStringFromDifferentArgs() = runComposeUiTest {
// we're putting different integers to arguments and the quantity
var quantity by mutableStateOf(0)
var arg by mutableStateOf("me")
var str1 = ""
var str2 = ""
setContent {
CompositionLocalProvider(LocalComposeEnvironment provides TestComposeEnvironment) {
str1 = pluralStringResource(TestQuantityStringResource("messages"), quantity, 3, arg)
str2 = pluralStringResource(TestQuantityStringResource("messages"), quantity, 5, arg)
}
}
waitForIdle()
assertEquals("3 messages for me", str1)
assertEquals("5 messages for me", str2)
arg = "you"
waitForIdle()
assertEquals("3 messages for you", str1)
assertEquals("5 messages for you", str2)
quantity = 1
waitForIdle()
assertEquals("3 message for you", str1)
assertEquals("5 message for you", str2)
}
@Test
fun testLoadQuantityStringResource() = runTest {
assertEquals("one", getQuantityString(TestQuantityStringResource("plurals"), 1))
assertEquals("other", getQuantityString(TestQuantityStringResource("plurals"), 5))
assertEquals("another one", getQuantityString(TestQuantityStringResource("another_plurals"), 1))
assertEquals("another other", getQuantityString(TestQuantityStringResource("another_plurals"), 5))
}
@Test
fun testMissingResource() = runTest {
assertFailsWith<MissingResourceException> {
@ -170,6 +266,18 @@ class ComposeResourceTest {
<item>item 2</item>
<item>item 3</item>
</string-array>
<plurals name="plurals">
<item quantity="one">one</item>
<item quantity="other">other</item>
</plurals>
<plurals name="another_plurals">
<item quantity="one">another one</item>
<item quantity="other">another other</item>
</plurals>
<plurals name="messages">
<item quantity="one">%1${'$'}d message for %2${'$'}s</item>
<item quantity="other">%1${'$'}d messages for %2${'$'}s</item>
</plurals>
</resources>
""".trimIndent(),

7
components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestUtils.kt

@ -5,4 +5,11 @@ internal fun TestStringResource(key: String) = StringResource(
"STRING:$key",
key,
setOf(ResourceItem(emptySet(), "strings.xml"))
)
@OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
internal fun TestQuantityStringResource(key: String) = QuantityStringResource(
"PLURALS:$key",
key,
setOf(ResourceItem(emptySet(), "strings.xml"))
)

12
components/resources/library/src/commonTest/resources/strings.xml

@ -8,4 +8,16 @@
<item>item 2</item>
<item>item 3</item>
</string-array>
<plurals name="plurals">
<item quantity="one">one</item>
<item quantity="other">other</item>
</plurals>
<plurals name="another_plurals">
<item quantity="one">another one</item>
<item quantity="other">another other</item>
</plurals>
<plurals name="messages">
<item quantity="one">%1$d message for %2$s</item>
<item quantity="other">%1$d messages for %2$s</item>
</plurals>
</resources>

12
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/GenerateResClassTask.kt

@ -102,8 +102,11 @@ internal abstract class GenerateResClassTask : DefaultTask() {
if (typeString == "values" && file.name.equals("strings.xml", true)) {
val stringIds = getStringIds(file)
val quantityStringIds = getQuantityStringIds(file)
return stringIds.map { strId ->
ResourceItem(ResourceType.STRING, qualifiers, strId.asUnderscoredIdentifier(), path)
} + quantityStringIds.map { quantityStrId ->
ResourceItem(ResourceType.QUANTITY_STRING, qualifiers, quantityStrId.asUnderscoredIdentifier(), path)
}
}
@ -121,6 +124,15 @@ internal abstract class GenerateResClassTask : DefaultTask() {
return ids.toSet()
}
private fun getQuantityStringIds(stringsXml: File): Set<String> {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(stringsXml)
val items = doc.getElementsByTagName("resources").item(0).childNodes
val ids = List(items.length) { items.item(it) }
.filter { it.nodeName == "plurals" }
.map { it.attributes.getNamedItem("name").nodeValue }
return ids.toSet()
}
private fun File.listNotHiddenFiles(): List<File> =
listFiles()?.filter { !it.isHidden }.orEmpty()
}

4
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt

@ -9,6 +9,7 @@ import kotlin.io.path.invariantSeparatorsPathString
internal enum class ResourceType(val typeName: String) {
DRAWABLE("drawable"),
STRING("string"),
QUANTITY_STRING("plurals"),
FONT("font");
override fun toString(): String = typeName
@ -31,6 +32,7 @@ internal data class ResourceItem(
private fun ResourceType.getClassName(): ClassName = when (this) {
ResourceType.DRAWABLE -> ClassName("org.jetbrains.compose.resources", "DrawableResource")
ResourceType.STRING -> ClassName("org.jetbrains.compose.resources", "StringResource")
ResourceType.QUANTITY_STRING -> ClassName("org.jetbrains.compose.resources", "QuantityStringResource")
ResourceType.FONT -> ClassName("org.jetbrains.compose.resources", "FontResource")
}
@ -225,7 +227,7 @@ private fun getChunkFileSpec(
CodeBlock.builder()
.add("return %T(\n", type.getClassName()).withIndent {
add("\"${type}:${resName}\",")
if (type == ResourceType.STRING) add(" \"$resName\",")
if (type == ResourceType.STRING || type == ResourceType.QUANTITY_STRING) add(" \"$resName\",")
withIndent {
add("\nsetOf(\n").withIndent {
items.forEach { item ->

Loading…
Cancel
Save