Git Product home page Git Product logo

lyricist's Introduction

Maven metadata URL Android API kotlin ktlint License MIT

Lyricist ๐ŸŒŽ๐ŸŒ๐ŸŒ

The missing I18N and L10N multiplatform library for Jetpack Compose!

Jetpack Compose greatly improved the way we build UIs on Android, but not how we interact with strings. stringResource() works well, but doesn't benefit from the idiomatic Kotlin like Compose.

Lyricist tries to make working with strings as powerful as building UIs with Compose, i.e., working with parameterized string is now typesafe, use of when expression to work with plurals with more flexibility, and even load/update the strings dynamically via an API!

Features

  • Multiplatform: Android, Desktop, iOS, Web (JsCanvas)
  • Simple API to handle locale changes and provide the current strings
  • Multi module support
  • Easy migration from strings.xml
  • Extensible: supports Compose Multiplatform out of the box but can be integrated on any UI Toolkit
  • Code generation with KSP

Limitations

  • The XML processor doesn't handle few and many plural values (PRs are welcome)

Why Lyricist?

Inspired by accompanist library: music composing is done by a composer, and since this library is about writing lyrics strings, the role of a lyricist felt like a good name.

Usage

Take a look at the sample app and sample-multi-module for working examples.

Start by declaring your strings on a data class, class or interface (pick one). The strings can be anything (really, it's up to you): Char, String, AnnotatedString, List<String>, Set<String> or even lambdas!

data class Strings(
    val simple: String,
    val annotated: AnnotatedString,
    val parameter: (locale: String) -> String,
    val plural: (count: Int) -> String,
    val list: List<String>,
    val nestedStrings: NestedStrings(),
)

data class NestedStrings(
    ...
)

Next, create instances for each supported language and annotate with @LyricistStrings. The languageTag must be an IETF BCP47 compliant language tag (docs). You must flag one of them as default.

@LyricistStrings(languageTag = Locales.EN, default = true)
val EnStrings = Strings(
    simple = "Hello Compose!",

    annotated = buildAnnotatedString {
        withStyle(SpanStyle(color = Color.Red)) { 
            append("Hello ") 
        }
        withStyle(SpanStyle(fontWeight = FontWeight.Light)) { 
            append("Compose!") 
        }
    },

    parameter = { locale ->
        "Current locale: $locale"
    },

    plural = { count ->
        val value = when (count) {
            0 -> "no"
            1, 2 -> "a few"
            in 3..10 -> "a bunch of"
            else -> "a lot of"
        }
        "I have $value apples"
    },

    list = listOf("Avocado", "Pineapple", "Plum")
)

@LyricistStrings(languageTag = Locales.PT)
val PtStrings = Strings(/* pt strings */)

@LyricistStrings(languageTag = Locales.ES)
val EsStrings = Strings(/* es strings */)

@LyricistStrings(languageTag = Locales.RU)
val RuStrings = Strings(/* ru strings */)

Lyricist will generate the LocalStrings property, a CompositionLocal that provides the strings of the current locale. It will also generate rememberStrings() and ProvideStrings(), call them to make LocalStrings accessible down the tree.

val lyricist = rememberStrings()

ProvideStrings(lyricist) {
    // Content
}

// Or just 
ProvideStrings {
    // Content
}

Optionally, you can specify the current and default (used as fallback) languages.

val lyricist = rememberStrings(
    defaultLanguageTag = "es-US", // Default value is the one annotated with @LyricistStrings(default = true)
    currentLanguageTag = getCurrentLanguageTagFromLocalStorage(),
)

Now you can use LocalStrings to retrieve the current strings.

val strings = LocalStrings.current

Text(text = strings.simple)
// > Hello Compose!

Text(text = strings.annotated)
// > Hello Compose!

Text(text = strings.parameter(lyricist.languageTag))
// > Current locale: en

Text(text = strings.plural(1))
Text(text = strings.plural(5))
Text(text = strings.plural(20))
// > I have a few apples
// > I have a bunch of apples
// > I have a lot of apples

Text(text = strings.list.joinToString())
// > Avocado, Pineapple, Plum

Use the Lyricist instance provided by rememberStrings() to change the current locale. This will trigger a recomposition that will update the entire content.

lyricist.languageTag = Locales.PT

Important

Lyricist uses the System locale as current language (on Compose it uses Locale.current). If your app has a mechanism to change the language in-app please set this value on rememberStrings(currentLanguageTag = CURRENT_VALUE_HERE).

If you change the current language at runtime Lyricist won't persist the value on a local storage by itself, this should be done by you. You can save the current language tag on shared preferences, a local database or even through a remote API.

Controlling the visibility

To control the visibility (public or internal) of the generated code, provide the following (optional) argument to KSP in the module's build.gradle.

ksp {
    arg("lyricist.internalVisibility", "true")
}

Generating a strings helper property

Instead of use LocalStrings.current to access your strings, you can simply call strings. Just provide the following (optional) argument to KSP in the module's build.gradle.

ksp {
    arg("lyricist.generateStringsProperty", "true")
}

After a successfully build you can refactor your code as below.

// Before
Text(text = LocalStrings.current.hello)

// After
Text(text = strings.hello)

Multi module settings

If you are using Lyricist on a multi module project and the generated declarations (LocalStrings, rememberStrings(), ProvideStrings()) are too generic for you, provide the following (optional) arguments to KSP in the module's build.gradle.

ksp {
    arg("lyricist.packageName", "com.my.app")
    arg("lyricist.moduleName", project.name)
}

Let's say you have a "dashboard" module, the generated declarations will be LocalDashboardStrings, rememberDashboardStrings() and ProvideDashboardStrings().

Migrating from strings.xml

So you liked Lyricist, but already have a project with thousands of strings spread over multiples files? I have good news for you: Lyricist can extract these existing strings and generate all the code you just saw above. If you don't want to have the Compose code generated by KSP, you can set the lyricist.xml.generateComposeAccessors arg to "false", and you can write the code manually by following the instructions below.

Similar to the multi module setup, you must provide a few arguments to KSP. Lyricist will search for strings.xml files in the resources path. You can also provide a language tag to be used as default value for the LocalStrings.

ksp {
    // Required
    arg("lyricist.xml.resourcesPath", android.sourceSets.main.res.srcDirs.first().absolutePath)
    
    // Optional
    arg("lyricist.packageName", "com.my.app")
    arg("lyricist.xml.moduleName", "xml")
    arg("lyricist.xml.defaultLanguageTag", "en")
    arg("lyricist.xml.generateComposeAccessors", "false")
}

After the first build, the well-known rememberStrings() and ProvideStrings() (naming can vary depending on your KSP settings) will be available for use. Lyricist will also generated a Locales object containing all language tags currently in use in your project.

val lyricist = rememberStrings(strings)

ProvideStrings(lyricist, LocalStrings) {
    // Content
}

lyricist.languageTag = Locales.PT

You can easily migrate from strings.xml to Lyricist just by copying the generated files to your project. That way, you can finally say goodbye to strings.xml.

Extending Lyricist

Writing the generated code from KSP manually

Don't want to enable KSP to generate the code for you? No problem! Follow the steps below to integrate with Lyricist manually.

  1. Map each supported language tag to their corresponding instances.
val strings = mapOf(
    Locales.EN to EnStrings,
    Locales.PT to PtStrings,
    Locales.ES to EsStrings,
    Locales.RU to RuStrings
)
  1. Create your LocalStrings and choose one translation as default.
val LocalStrings = staticCompositionLocalOf { EnStrings }
  1. Use the same functions, rememberStrings() and ProvideStrings(), to make your LocalStrings accessible down the tree. But this time you need to provide your strings and LocalStrings manually.
val lyricist = rememberStrings(strings)

ProvideStrings(lyricist, LocalStrings) {
    // Content
}
Supporting other UI Toolkits

At the moment Lyricist only supports Jetpack Compose and Compose Multiplatform out of the box. If you need to use Lyricist with other UI Toolkit (Android Views, SwiftUI, Swing, GTK...) follow the instructions bellow.

  1. Map each supported language tag to their corresponding instances
val translations = mapOf(
    Locales.EN to EnStrings,
    Locales.PT to PtStrings,
    Locales.ES to EsStrings,
    Locales.RU to RuStrings
)
  1. Create an instance of Lyricist, can be a project-wide singleton or a local instance per module
val lyricist = Lyricist(defaultLanguageTag, translations)
  1. Collect Lyricist state and notify the UI to update whenever it changes
lyricist.state.collect { (languageTag, strings) ->
    refreshUi(strings)
}

// Example for Compose
val state by lyricist.state.collectAsState()

CompositionLocalProvider(
    LocalStrings provides state.strings
) {
    // Content
}

Troubleshooting

Can't use the generated code on my IDE

You should set manually the source sets of the generated files, like described here.

buildTypes {
    debug {
        sourceSets {
            main.java.srcDirs += 'build/generated/ksp/debug/kotlin/'
        }
    }
    release {
        sourceSets {
            main.java.srcDirs += 'build/generated/ksp/release/kotlin/'
        }
    }
}

Import to your project

  1. Importing the KSP plugin in the project's build.gradle then apply to your module's build.gradle
plugins {
    id("com.google.devtools.ksp") version "${ksp-latest-version}"
}
  1. Add the desired dependencies to your module's build.gradle
// Required
implementation("cafe.adriel.lyricist:lyricist:${latest-version}")

// If you want to use @LyricistStrings to generate code for you
ksp("cafe.adriel.lyricist:lyricist-processor:${latest-version}")

// If you want to migrate from strings.xml
ksp("cafe.adriel.lyricist:lyricist-processor-xml:${latest-version}")

Version Catalog

[versions]
lyricist = {latest-version}

[libraries]
lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" }
lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" }
lyricist-processorXml = { module = "cafe.adriel.lyricist:lyricist-processor-xml", version.ref = "lyricist" }

Multiplatform setup

Doing code generation only at commonMain. Currently workaround, for more information see KSP Issue 567

dependencies {
    add("kspCommonMainMetadata", "cafe.adriel.lyricist:lyricist-processor:${latest-version}")
}

tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>>().all {
    if(name != "kspCommonMainKotlinMetadata") {
        dependsOn("kspCommonMainKotlinMetadata")
    }
}

kotlin.sourceSets.commonMain {
    kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}

Current version: Maven metadata URL

lyricist's People

Contributors

adrielcafe avatar devnatan avatar devsrsouza avatar phucynwa avatar prof18 avatar skaldebane avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

lyricist's Issues

XML processor ignores positional index on parameters

When specifying printf format strings you can include a parameter index to refer to which of the arguments you are referring to. For example this would include the first parameter followed by the second parameter

    <string name="foo">%1$d - %2$s</string>

This is important because not all translations will use the paramters in the same order and in some cases may use a paramter more than once. A particular translation is allowed to do something like:

    <string name="foo">%2$s - %1$d - %2$s</string>

The XML processor simply throws away the index and assumes that all translations use the parameters only once and in the same order. For the first one it will generate a function with Int and String parameters. The second one will generate parameters of String, Int, String

Support noncompose accessors

Issue

Currently, the library allows you to access strings declared within the bounds of composables. On multiplatform, however, you'd have cases where you have APIs that need to access strings outside of compose. A simple use case would be displaying notifications as a result of some background task.

Proposal

Add a convenient accessor in the generated cafe.adriel.lyricist.Strings.kt file

fun getStrings():Strings {
    return  Strings[Locale.current.toLanguageTag()]?: defaultStrings
 }

Do let me know what you make of it or if you need more information.

xml processor changing the names of strings can lead to conflicts

I tried running the XML processor on our strings and ended up getting a conflict where the generated code had variables with the same name. After some digging I discovered what the problem is.

In our string files we ended up having 2 strings that were named almost the same

    <string name="pending_label">PENDING</string>
    <string name="pendingLabel">PENDING</string>

Oddly enough in 1 language they even had different translations.

Because the XML processor converts all the names to camel case they ended up being mapped to the same name which produced duplicate items both with the name pendingLabel.

The processor should at least detect such a conflict and handle it more appropriately than generating code that does not compile.

@LyricistStrings can't be internal

When I try to make my val annoted with @LyricistStrings internal, my app doesn't compile ๐Ÿ˜•

error:
'public' property exposes its 'internal' type argument MyModuleToolsStrings

XML processor generates invalid code for printf style formatting

If you have a string that has printf format specifiers like:

    <string name="total_duration">%1$s Total</string>

It generates:

    override val totalDuration = { p0: String -> 
        "%s Total"
            .format(p0)
    }

Problem is there is no such format method on string at least in common code.

For most simple cases you could simply generate string interpolation code and if the format specifier is more complex generate warnings

Configuration cache not supported

I used this with unsafe gradle cache and this error triggerd

Configuration cache state could not be cached: field `__libraries__` of task `:i18n:kspCommonMainKotlinMetadata` of type `com.google.devtools.ksp.gradle.KspTaskMetadata`: error writing value of type 'org.gradle.api.internal.file.collections.DefaultConfigurableFileCollection'
> Querying the mapped value of task ':i18n:transformCommonMainDependenciesMetadata' property 'transformedLibrariesIndexFile' before task ':i18n:transformCommonMainDependenciesMetadata' has completed is not supported

with gradle cache I mean adding this code to gradle.properties

org.gradle.unsafe.configuration-cache=true
# Use this flag sparingly, in case some of the plugins are not fully compatible
org.gradle.unsafe.configuration-cache-problems=warn

Support Layout Direction alongside Resources

Feature Request: Support Layout Direction alongside Resources

Description:

The lyricist library is a valuable resource for managing multi-language applications. However, it currently lacks a crucial feature for handling layout direction alongside these resources, especially in languages that require right-to-left (RTL) layout direction like Arabic, Hebrew, and Persian.

In RTL languages, it's essential not only to localize strings but also to ensure that the overall layout of the application adjusts appropriately. This involves mirroring layouts, aligning text correctly, and adapting the UI to suit RTL languages.

Requested Feature:

I propose the addition of a new feature within lyricist that seamlessly integrates support for layout direction alongside its existing resource management capabilities. This feature would enable developers to specify layout direction preferences per language/locale, ensuring that the UI adapts dynamically based on the user's language settings.

Expected Behavior:

Ability to specify layout direction (LTR or RTL) for each supported language/locale.
Automatic adjustment of UI elements based on the specified layout direction.
Seamless integration with existing resource management workflows within lyricist.

Use Case:

Imagine an application that supports both English (LTR) and Arabic (RTL) languages. With this proposed feature, developers can easily configure resources and layout direction preferences for each language, resulting in a more polished and user-friendly experience for RTL language users.

Additional Notes:

This feature would significantly enhance the usability and accessibility of applications developed using lyricist in multi-language environments. It aligns with the library's goal of simplifying resource management and localization for Kotlin Multiplatform projects.

I appreciate your consideration of this feature request and look forward to any feedback or updates regarding its potential inclusion in future releases of lyricist.

Thank you for your attention to this matter.

Please feel free to reach out if further clarification or details are needed.

Adding Icon Into The Text

Adding Image or preferably any composable into the text would be great to achieve something like:

Please click [icon] button.

Please click [compsable] button.

Sample is not working

I am getting below error when run the sample. changing com.android.tools.build:gradle version didn't work

`Execution failed for task ':buildSrc:compileKotlin'.
> Could not resolve all files for configuration ':buildSrc:compileClasspath'.
   > Could not resolve com.android.tools.build:gradle:8.1.2.
     Required by:
         project :buildSrc
      > No matching variant of com.android.tools.build:gradle:8.1.2 was found. The consumer was configured to find a library for use during compile-time, compatible with Java 8, preferably not packaged as a jar, preferably optimized for standard JVMs, and its dependencies declared externally, as well as attribute 'org.gradle.plugin.api-version' with value '8.2.1', attribute 'org.jetbrains.kotlin.platform.type' with value 'jvm' but:
          - Variant 'apiElements' capability com.android.tools.build:gradle:8.1.2 declares a library for use during compile-time, packaged as a jar, preferably optimized for standard JVMs, and its dependencies declared externally, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'jvm':
              - Incompatible because this component declares a component, compatible with Java 11 and the consumer needed a component, compatible with Java 8
              - Other compatible attribute:
                  - Doesn't say anything about org.gradle.plugin.api-version (required '8.2.1')
          - Variant 'javadocElements' capability com.android.tools.build:gradle:8.1.2 declares a component for use during runtime, and its dependencies declared externally:
              - Incompatible because this component declares documentation and the consumer needed a library
              - Other compatible attributes:
                  - Doesn't say anything about its target Java environment (preferred optimized for standard JVMs)
                  - Doesn't say anything about its target Java version (required compatibility with Java 8)
                  - Doesn't say anything about its elements (required them preferably not packaged as a jar)
                  - Doesn't say anything about org.gradle.plugin.api-version (required '8.2.1')
                  - Doesn't say anything about org.jetbrains.kotlin.platform.type (required 'jvm')`

Bug No @Strings(default = true) found

This error occurs during the execution of the ./gradlew test, But there is no problem when run project.
Also if we not use ksp for generate code all thing be fine.

e: Error occurred in KSP, check log for detail
java.lang.IllegalArgumentException: No @Strings(default = true) found
	at cafe.adriel.lyricist.processor.internal.LyricistSymbolProcessor.validate(LyricistSymbolProcessor.kt:113)
	at cafe.adriel.lyricist.processor.internal.LyricistSymbolProcessor.finish(LyricistSymbolProcessor.kt:35)
	at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$8$1.invoke(KotlinSymbolProcessingExtension.kt:224)
	at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$8$1.invoke(KotlinSymbolProcessingExtension.kt:223)
	at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.handleException(KotlinSymbolProcessingExtension.kt:287)
	at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:223)
	at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:120)
	at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:96)
	at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler$analyze$1.invoke(KotlinToJVMBytecodeCompiler.kt:262)
	at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler$analyze$1.invoke(KotlinToJVMBytecodeCompiler.kt:53)
	at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:113)
	at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:253)
	at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:100)
	at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli$default(KotlinToJVMBytecodeCompiler.kt:58)
	at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170)
	at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:52)
	at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:92)
	at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:44)
	at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:98)
	at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1618)
	at jdk.internal.reflect.GeneratedMethodAccessor106.invoke(Unknown Source)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:359)
	at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
	at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
	at java.base/java.security.AccessController.doPrivileged(Native Method)
	at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
	at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:562)
	at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:796)
	at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:677)
	at java.base/java.security.AccessController.doPrivileged(Native Method)
	at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:676)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
	at java.base/java.lang.Thread.run(Thread.java:834)

xml processor does not support xliff:g elements

It is a common practice to embed elements like this in string files to provide clues to translators:

    <string name="time_range_format"><xliff:g id="startTime" example="1:30 PM">%1$s</xliff:g> - <xliff:g id="endTime" example="3:30 PM">%2$s</xliff:g></string>

But the xml processor does not support them and gives an error:

[ksp] com.gitlab.mvysny.konsumexml.KonsumerException: line 11 column 80 at null file:///Users/dalewking/Downloads/Foo/composeApp/src/strings/values-it/strings.xml, in element : Expected text but got START_ELEMENT: {urn:oasis:names:tc:xliff:document:1.2}g

UI not recomposed when locale changes

If you are in the app, go to settings and change the locale to another one that is supported the UI is not automatically recomposed so none of the strings will change to the new locale

Strings for language tag not found

Hi, I'm trying to use this library without the KSP generation and following the README for writing the code myself. However, following these instructions lead to an issue where the library doesn't fall back to the default I want if it can't find a match for the system language setting.

Minimal example:

package com.example.lyricisttest

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.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cafe.adriel.lyricist.LyricistStrings
import cafe.adriel.lyricist.ProvideStrings
import cafe.adriel.lyricist.rememberStrings
import com.example.lyricisttest.ui.theme.LyricistTestTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LyricistTestTheme {
                val lyricist = rememberStrings(strings/*, Locales.ENGLISH*/)
                ProvideStrings(lyricist, LocalStrings) {
                    Surface(
                        modifier = Modifier.fillMaxSize(),
                        color = MaterialTheme.colors.background
                    ) {
                        Box(modifier = Modifier.fillMaxSize()) {
                            Text(
                                text = LocalStrings.current.helloWorld,
                                modifier = Modifier.align(Alignment.Center),
                                fontSize = 32.sp,
                            )
                            Text(
                                text = Locale.current.toLanguageTag(),
                                modifier = Modifier
                                    .align(Alignment.BottomCenter)
                                    .padding(bottom = 40.dp),
                                fontSize = 24.sp,
                            )
                        }
                    }
                }
            }
        }
    }
}

data class Strings(
    val helloWorld: String,
)

object Locales {
    const val ENGLISH = "en"
    const val SPANISH = "es"
}

@LyricistStrings(languageTag = Locales.ENGLISH, default = true)
val english = Strings(
    helloWorld = "Hello world"
)

@LyricistStrings(languageTag = Locales.SPANISH)
val spanish = Strings(
    helloWorld = "Hola mundo"
)

val strings = mapOf(
    Locales.ENGLISH to english,
    Locales.SPANISH to spanish,
)

val LocalStrings = staticCompositionLocalOf { english }

I would expect this app to display "Hello world" if the locale is English, and "Hola mundo" if the locale is Spanish, and fallback to English if the locale is something else. As long as the locale is English or Spanish this seems to be the case:

Screenshot_1666357623
Screenshot_1666357637
Screenshot_1666357649
Screenshot_1666357660

All four US and UK English and Spanish combinations work as expected.

However, if I change the system language to something else, Portuguese, instead of falling back to English the app crashes.

2022-10-21 14:07:51.546 28520-28520/? E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.example.lyricisttest, PID: 28520
    java.lang.IllegalStateException: Strings for language tag pt-PT not found
        at cafe.adriel.lyricist.Lyricist.getStrings(Lyricist.kt:25)
        at cafe.adriel.lyricist.LyricistUtilsKt.ProvideStrings(LyricistUtils.kt:25)
        at com.example.lyricisttest.ComposableSingletons$MainActivityKt$lambda-3$1.invoke(MainActivity.kt:29)
        at com.example.lyricisttest.ComposableSingletons$MainActivityKt$lambda-3$1.invoke(MainActivity.kt:27)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:107)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:34)
        at androidx.compose.material.MaterialTheme_androidKt.PlatformMaterialTheme(MaterialTheme.android.kt:23)
        at androidx.compose.material.MaterialThemeKt$MaterialTheme$1$1.invoke(MaterialTheme.kt:82)
        at androidx.compose.material.MaterialThemeKt$MaterialTheme$1$1.invoke(MaterialTheme.kt:81)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:107)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:34)
        at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:228)
        at androidx.compose.material.TextKt.ProvideTextStyle(Text.kt:265)
        at androidx.compose.material.MaterialThemeKt$MaterialTheme$1.invoke(MaterialTheme.kt:81)
        at androidx.compose.material.MaterialThemeKt$MaterialTheme$1.invoke(MaterialTheme.kt:80)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:107)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:34)
        at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:228)
        at androidx.compose.material.MaterialThemeKt.MaterialTheme(MaterialTheme.kt:72)
        at com.example.lyricisttest.ui.theme.ThemeKt.LyricistTestTheme(Theme.kt:38)
        at com.example.lyricisttest.ComposableSingletons$MainActivityKt$lambda-4$1.invoke(MainActivity.kt:27)
        at com.example.lyricisttest.ComposableSingletons$MainActivityKt$lambda-4$1.invoke(MainActivity.kt:26)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:107)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:34)
        at androidx.compose.ui.platform.ComposeView.Content(ComposeView.android.kt:404)
        at androidx.compose.ui.platform.AbstractComposeView$ensureCompositionCreated$1.invoke(ComposeView.android.kt:250)
        at androidx.compose.ui.platform.AbstractComposeView$ensureCompositionCreated$1.invoke(ComposeView.android.kt:249)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:107)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:34)
        at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:228)
        at androidx.compose.ui.platform.CompositionLocalsKt.ProvideCommonCompositionLocals(CompositionLocals.kt:177)
        at androidx.compose.ui.platform.AndroidCompositionLocals_androidKt$ProvideAndroidCompositionLocals$3.invoke(AndroidCompositionLocals.android.kt:123)
        at androidx.compose.ui.platform.AndroidCompositionLocals_androidKt$ProvideAndroidCompositionLocals$3.invoke(AndroidCompositionLocals.android.kt:122)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:107)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:34)
        at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:228)
        at androidx.compose.ui.platform.AndroidCompositionLocals_androidKt.ProvideAndroidCompositionLocals(AndroidCompositionLocals.android.kt:114)
        at androidx.compose.ui.platform.WrappedComposition$setContent$1$1$3.invoke(Wrapper.android.kt:157)
        at androidx.compose.ui.platform.WrappedComposition$setContent$1$1$3.invoke(Wrapper.android.kt:156)
2022-10-21 14:07:51.547 28520-28520/? E/AndroidRuntime:     at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:107)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:34)
        at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:228)
        at androidx.compose.ui.platform.WrappedComposition$setContent$1$1.invoke(Wrapper.android.kt:156)
        at androidx.compose.ui.platform.WrappedComposition$setContent$1$1.invoke(Wrapper.android.kt:140)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:107)
        at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:34)
        at androidx.compose.runtime.ActualJvm_jvmKt.invokeComposable(ActualJvm.jvm.kt:78)
        at androidx.compose.runtime.ComposerImpl$doCompose$2$5.invoke(Composer.kt:3248)
        at androidx.compose.runtime.ComposerImpl$doCompose$2$5.invoke(Composer.kt:3238)
        at androidx.compose.runtime.SnapshotStateKt__DerivedStateKt.observeDerivedStateRecalculations(DerivedState.kt:341)
        at androidx.compose.runtime.SnapshotStateKt.observeDerivedStateRecalculations(Unknown Source:1)
        at androidx.compose.runtime.ComposerImpl.doCompose(Composer.kt:3238)
        at androidx.compose.runtime.ComposerImpl.composeContent$runtime_release(Composer.kt:3173)
        at androidx.compose.runtime.CompositionImpl.composeContent(Composition.kt:587)
        at androidx.compose.runtime.Recomposer.composeInitial$runtime_release(Recomposer.kt:950)
        at androidx.compose.runtime.CompositionImpl.setContent(Composition.kt:519)
        at androidx.compose.ui.platform.WrappedComposition$setContent$1.invoke(Wrapper.android.kt:140)
        at androidx.compose.ui.platform.WrappedComposition$setContent$1.invoke(Wrapper.android.kt:131)
        at androidx.compose.ui.platform.AndroidComposeView.setOnViewTreeOwnersAvailable(AndroidComposeView.android.kt:1060)
        at androidx.compose.ui.platform.WrappedComposition.setContent(Wrapper.android.kt:131)
        at androidx.compose.ui.platform.WrappedComposition.onStateChanged(Wrapper.android.kt:182)
        at androidx.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.java:360)
        at androidx.lifecycle.LifecycleRegistry.addObserver(LifecycleRegistry.java:202)
        at androidx.compose.ui.platform.WrappedComposition$setContent$1.invoke(Wrapper.android.kt:138)
        at androidx.compose.ui.platform.WrappedComposition$setContent$1.invoke(Wrapper.android.kt:131)
        at androidx.compose.ui.platform.AndroidComposeView.onAttachedToWindow(AndroidComposeView.android.kt:1147)
        at android.view.View.dispatchAttachedToWindow(View.java:19553)
        at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3430)
        at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3437)
        at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3437)
        at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3437)
        at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3437)
        at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:2028)
        at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:1721)
        at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:7598)
        at android.view.Choreographer$CallbackRecord.run(Choreographer.java:966)
        at android.view.Choreographer.doCallbacks(Choreographer.java:790)
        at android.view.Choreographer.doFrame(Choreographer.java:725)
        at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:951)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.app.ActivityThread.main(ActivityThread.java:7356)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)

I can see the default argument for languageTag in rememberStrings is Locale.current.toLanguageTag(), which gets passed into the instance of Lyricist as the defaultLanguageTag. If I override that argument as in the commented code in the example above, I can force the language to English, which prevents the crash but also stops the localisation working for Spanish.

Performance?

What's the performance of this library? Do you plan to add any benchmarks?

On first sight there appears to be a longer startup time with this library vs a custom platform-specific implementation, but maybe I'm doing something wrong. Thus, some more information about performance would clear things up for me (and many others)!

Click to see example

Example custom implementation w/o this lib

This is what my setup looked like without this library.

commonMain/.../L18n.kt:

@Composable
@ReadOnlyComposable
expect fun translate(key: String): String

androidMain/.../L18n.kt:

package com.example.common.platform

import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import com.example.common.R

@ReadOnlyComposable
@Composable
actual fun translate(key: String): String {
    val stringClass = R.string::class.java
    val field = stringClass.getDeclaredField(key)
    return stringResource(field.get(stringClass) as Int)
}

iosMain/.../L18n.kt:

Not implemented yet, but same principle

too many argument Bug

When the xml has too many properties like over 162 this error is throws in jvm

Exception in thread "main" java.lang.ClassFormatError: Too many arguments in method signature in class file ireader/i18n/XmlStrings

and in android it throws

FATAL EXCEPTION: main
 Process: ir.kazemcodes.infinityreader.debug, PID: 8153
 java.lang.VerifyError: Verifier rejected class ireader.i18n.EnXmlStringsKt: void ireader.i18n.EnXmlStringsKt.<clinit>() failed to verify: void ireader.i18n.EnXmlStringsKt.<clinit>(): [0x539] Rejecting invocation, expected 162 argument registers, method signature has 163 or more (declaration of 'ireader.i18n.EnXmlStringsKt' appears in /data/data/ir.kazemcodes.infinityreader.debug/code_cache/.overlay/base.apk/classes6.dex)

maybe its because of Kotlin/kotlinx.serialization#1393 and Kotlin/kotlinx.serialization#632

sample project: https://github.com/IReaderorg/IReader/tree/migrate_to_lyrstic

Exception Thrown When Enabling Gradle Cache

when I enable caching by

org.gradle.unsafe.configuration-cache=true
# Use this flag sparingly, in case some of the plugins are not fully compatible
org.gradle.unsafe.configuration-cache-problems=warn

this exception is thrown

<get-metadataDependencyResolutions>(...) must not be null

and sometimes

Configuration cache state could not be cached: field `__libraries__` of task `:i18n:kspCommonMainKotlinMetadata` of type `com.google.devtools.ksp.gradle.KspTaskMetadata`: error writing value of type 'org.gradle.api.internal.file.collections.DefaultConfigurableFileCollection'
> Querying the mapped value of task ':i18n:transformCommonMainDependenciesMetadata' property 'transformedLibrariesIndexFile' before task ':i18n:transformCommonMainDependenciesMetadata' has completed is not supported

[XML] String resource references are not resolved

In Android, the following is valid:

<string name="follow_show_add">Follow</string>
<string name="cd_follow_show_add">@string/follow_show_add</string>

Lyricist's XML processor currently uses that string verbatim:

val EnXmlStrings = XmlStrings(
  followShowAdd = "Follow show",
  cdFollowShowAdd = "@string/follow_show_add",
)

Would be great if we could resolve this in the processor, and inline the value again?

Fatal error with > 255 translation strings

According to the JVM spec:

The number of method parameters is limited to 255 by the definition of a method descriptor.

Because of this, it seems impossible to have more than 255 translation strings defined with lyricist:

Strings.kt:

package com.example.translations

data class Strings(
  val s1: String,
  val s2: String,
  val s3: String,
  // ...
  val s256: String,
)

EnUSStrings.kt:

package com.example.translations

import cafe.adriel.lyricist.LyricistStrings

@LyricistStrings(languageTag = Locales.EN_US, default = true)
val EnUSStrings = Strings(
    s1 = "1"
    s2 = "2"
    s3 = "3"
    // ...
    s256 = "256" // starts throwing obscure error at this point
)

The project builds fine, but I get the following obscure error at launch time:

FATAL EXCEPTION: main
    Process: com.example.app, PID: 3919
    java.lang.VerifyError: Verifier rejected class com.example.translations.EnUSStringsKt: void com.example.translations.EnUSStringsKt.<clinit>() failed to verify: void com.example.translations.EnUSStringsKt.<clinit>(): [0x1B5D] Rejecting invocation, expected 32 argument registers, method signature has 33 or more (declaration of 'com.example.translations.EnUSStringsKt' appears in /data/app/~~om5MwkBVj9HGhO0i8RIbKQ==/com.example.app-9CsHVxFyi-qyH5k4yNO6ZQ==/base.apk)
        at com.example.translations.EnUSStringsKt.getEnUSStrings(EnUSStrings.kt:6)
        ...

And after doing some research about this error it leads me to the aforementioned JVM limit, which makes sense in my case.

Is there a work around to get around this limitation? Or am I missing something?

Extra indentation with XML processor

Very trivial, but I noticed that the strings files generated by the XML processor have extra indentation on the first property in the object.

Rejecting invocation, expected 6 argument registers, method signature has 7 or more

 java.lang.VerifyError: Verifier rejected class cafe.adriel.lyricist.JaStringsKt: void cafe.adriel.lyricist.JaStringsKt.<clinit>() failed to verify: void cafe.adriel.lyricist.JaStringsKt.<clinit>(): [0x79F] Rejecting invocation, expected 6 argument registers, method signature has 7 or more (declaration of 'cafe.adriel.lyricist.JaStringsKt' appears in /data/data/com.xx.xx/code_cache/.overlay/base.apk/classes5.dex)

It seems that an error occurs because there are too many fields in the data class.

corner case in example code

plural = { count ->
        val value = when (count) {
            1, 2 -> "few"
            in 3..10 -> "bunch of"
            else -> "lot of"
        }
        "I have a $value apples"
    }

If put count = 0 it will return "lot of" which is not true.

Unknown issue

I have implemented this Library into 2 of my project in one of my projects it just works fine eveything works good the ProvideStrings works well same for the rememberStrings but for my other project it dosent just to mention that the first project is desktop fro both windows and macos and the second project which i had issue on it was a full on multiplatfrom porject android , ios, desktop and web. in that project the ProvideStrings wasint working plus the rememberStrings always had this error that
Not enough information to infer type variable T but i was able to make the translation work

Java runtime version too high

Hi! I just updated to 1.6.0 from 1.4.2 today, but my project no longer builds anymore.

Apparently in a recent commit you removed the Java 1.8 constraint, and now the library is built and published with Java 19, a non-LTS version of Java.

This happened to another library these days as well. While the latest LTS Java release, 21, has been released, it's still very new and may not be available in development environments yet. Consider using Java 17 for now.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.