Skip to content

Commit

Permalink
feat: decouple API from Compose
Browse files Browse the repository at this point in the history
  • Loading branch information
adrielcafe committed Oct 23, 2023
1 parent 16ac28c commit b6d8940
Show file tree
Hide file tree
Showing 11 changed files with 197 additions and 89 deletions.
137 changes: 97 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Lyricist tries to make working with strings as powerful as building UIs with Com
- [x] [Simple API](#usage) to handle locale changes and provide the current strings
- [x] [Multi module support](#multi-module-settings)
- [x] [Easy migration](#migrating-from-stringsxml) from `strings.xml`
- [x] [Extensible](#extending-lyricist): supports Compose Multiplatform out of the box but can be integrated on any UI Toolkit
- [x] Code generation with [KSP](https://github.com/google/ksp)

#### Limitations
Expand Down Expand Up @@ -99,37 +100,14 @@ ProvideStrings {
}
```

<details><summary>Writing the code for yourself</summary>

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

First, map each supported language tag to their corresponding instances.
Optionally, you can specify the current and default (used as fallback) languages.
```kotlin
val strings = mapOf(
Locales.EN to EnStrings,
Locales.PT to PtStrings,
Locales.ES to EsStrings,
Locales.RU to RuStrings
val lyricist = rememberStrings(
defaultLanguageTag = "es-US", // Default value is the one annotated with @LyricistStrings(default = true)
currentLanguageTag = getCurrentLanguageTagFromLocalStorage(),
)
```

Next, create your `LocalStrings` and choose one translation as default.
```kotlin
val LocalStrings = staticCompositionLocalOf { EnStrings }
```

Finally, 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.
```kotlin
val lyricist = rememberStrings(strings)

ProvideStrings(lyricist, LocalStrings) {
// Content
}
```

---
</details>

Now you can use `LocalStrings` to retrieve the current strings.
```kotlin
val strings = LocalStrings.current
Expand All @@ -154,11 +132,17 @@ 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](https://developer.android.com/jetpack/compose/mental-model#recomposition) that will update the strings wherever they are being used.
Use the Lyricist instance provided by `rememberStrings()` to change the current locale. This will trigger a [recomposition](https://developer.android.com/jetpack/compose/mental-model#recomposition) that will update the entire content.
```kotlin
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`.
```gradle
Expand All @@ -174,8 +158,14 @@ ksp {
arg("lyricist.generateStringsProperty", "true")
}
```
After a successfully build you can refactor your code as below.
```kotlin
// Before
Text(text = LocalStrings.current.hello)

**Important:** Lyricist uses the System locale as default. It won't persist the current locale on storage, is outside its scope.
// After
Text(text = strings.hello)
```

## Multi module settings

Expand Down Expand Up @@ -218,6 +208,73 @@ 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

<details><summary>Writing the generated code from KSP manually</summary>

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.
```kotlin
val strings = mapOf(
Locales.EN to EnStrings,
Locales.PT to PtStrings,
Locales.ES to EsStrings,
Locales.RU to RuStrings
)
```

2. Create your `LocalStrings` and choose one translation as default.
```kotlin
val LocalStrings = staticCompositionLocalOf { EnStrings }
```

3. 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.
```kotlin
val lyricist = rememberStrings(strings)

ProvideStrings(lyricist, LocalStrings) {
// Content
}
```
</details>

<details><summary>Supporting other UI Toolkits</summary>

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
```kotlin
val translations = mapOf(
Locales.EN to EnStrings,
Locales.PT to PtStrings,
Locales.ES to EsStrings,
Locales.RU to RuStrings
)
```

2. Create an instance of Lyricist, can be a project-wide singleton or a local instance per module
```kotlin
val lyricist = Lyricist(defaultLanguageTag, translations)
```

3. Collect Lyricist state and notify the UI to update whenever it changes
```kotlin
lyricist.state.collect { (languageTag, strings) ->
refreshUi(strings)
}

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

CompositionLocalProvider(
LocalStrings provides state.strings
) {
// Content
}
```
</details>

## Troubleshooting

<details><summary>Can't use the generated code on my IDE</summary>
Expand Down Expand Up @@ -260,6 +317,17 @@ ksp("cafe.adriel.lyricist:lyricist-processor:${latest-version}")
ksp("cafe.adriel.lyricist:lyricist-processor-xml:${latest-version}")
```

#### Version Catalog
```toml
[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](https://github.com/google/ksp/issues/567)
Expand All @@ -279,15 +347,4 @@ kotlin.sourceSets.commonMain {
}
```

#### Version Catalog
```toml
[versions]
lyricist = {latest-version}

[libraries]
lyricist-library = { 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" }
```

Current version: ![Maven metadata URL](https://img.shields.io/maven-metadata/v?color=blue&metadataUrl=https://s01.oss.sonatype.org/service/local/repo_groups/public/content/cafe/adriel/lyricist/lyricist/maven-metadata.xml)
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/Setup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,4 @@ private fun KotlinJvmOptions.configureKotlinJvmOptions(

private fun Project.findAndroidExtension(): BaseExtension = extensions.findByType<LibraryExtension>()
?: extensions.findByType<com.android.build.gradle.AppExtension>()
?: error("Could not found Android application or library plugin applied on module $name")
?: error("Could not find Android application or library plugin applied on module $name")
11 changes: 10 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugin-maven = "0.22.0"

kotlin = "1.9.10"

coroutines = "1.7.3"
ksp = "1.9.10-1.0.13"
caseFormat = "0.2.0"
konsumeXml = "1.0"
Expand All @@ -25,6 +26,7 @@ plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.
plugin-ksp = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
plugin-compose-multiplatform = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "composeMultiplatform" }

coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
ksp = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
caseFormat = { module = "com.fleshgrinder.kotlin:case-format", version.ref = "caseFormat" }
konsumeXml = { module = "com.gitlab.mvysny.konsume-xml:konsume-xml", version.ref = "konsumeXml" }
Expand All @@ -38,4 +40,11 @@ compose-material = { module = "androidx.compose.material:material", version.ref
test-junit = { module = "androidx.test.ext:junit-ktx", version.ref = "test-junit" }

[bundles]
plugins = ["plugin-android", "plugin-ktlint", "plugin-maven", "plugin-kotlin", "plugin-ksp", "plugin-compose-multiplatform"]
plugins = [
"plugin-android",
"plugin-ktlint",
"plugin-maven",
"plugin-kotlin",
"plugin-ksp",
"plugin-compose-multiplatform",
]
1 change: 1 addition & 0 deletions lyricist-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ kotlin {
val commonMain by getting {
dependencies {
api(project(":lyricist-core"))
implementation(libs.coroutines)
compileOnly(compose.runtime)
compileOnly(compose.ui)
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,35 @@ package cafe.adriel.lyricist

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.text.intl.Locale

@Composable
public fun <T> rememberStrings(
translations: Map<LanguageTag, T>,
languageTag: LanguageTag = Locale.current.toLanguageTag()
defaultLanguageTag: LanguageTag = "en",
currentLanguageTag: LanguageTag = Locale.current.toLanguageTag()
): Lyricist<T> =
remember { Lyricist(languageTag, translations) }
remember(defaultLanguageTag) {
Lyricist(defaultLanguageTag, translations)
}.apply {
languageTag = currentLanguageTag
}

@Composable
public fun <T> ProvideStrings(
lyricist: Lyricist<T>,
provider: ProvidableCompositionLocal<T>,
content: @Composable () -> Unit
) {
val state by lyricist.state.collectAsState()

CompositionLocalProvider(
provider provides lyricist.strings,
provider provides state.strings,
content = content
)
}
10 changes: 10 additions & 0 deletions lyricist-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,13 @@ android {
}

kotlinMultiplatform()

kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.coroutines)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package cafe.adriel.lyricist

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

public typealias LanguageTag = String

public class Lyricist<T>(
private val defaultLanguageTag: LanguageTag,
private val translations: Map<LanguageTag, T>
) {

private val mutableState: MutableStateFlow<LyricistState<T>> =
MutableStateFlow(LyricistState(defaultLanguageTag, getStrings(defaultLanguageTag)))

public val state: StateFlow<LyricistState<T>> =
mutableState.asStateFlow()

public var languageTag: LanguageTag
get() = mutableState.value.languageTag
set(languageTag) {
mutableState.value = LyricistState(languageTag, getStrings(languageTag))
}

public val strings: T
get() = mutableState.value.strings

private val LanguageTag.fallback: LanguageTag
get() = split(FALLBACK_REGEX).first()

private fun getStrings(languageTag: LanguageTag) =
translations[languageTag]
?: translations[languageTag.fallback]
?: translations[defaultLanguageTag]
?: throw LyricistException("Strings for language tag $languageTag not found")

private companion object {
private val FALLBACK_REGEX = Regex("[-_]")
}
}

public data class LyricistState<T> internal constructor(
val languageTag: LanguageTag,
val strings: T,
)

public class LyricistException internal constructor(
override val message: String
) : RuntimeException()

@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
public annotation class LyricistStrings(
val languageTag: LanguageTag,
val default: Boolean = false
)

This file was deleted.

Loading

0 comments on commit b6d8940

Please sign in to comment.