Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,49 @@ Benefits:
- Keep UI code clean and focused on layout.
- Centralize localization, formatting, and pluralization.

2 common usecases **TextResource** solves

### Example 1: Exposing data to build strings
Without **TextResource**, you might pass raw data (like a name or count) up to the UI just so it can build a string:
```kotlin
// Without TextResource
// ViewModel exposes raw fields
val userName = "Derek"
val messageCount = 3

// UI has to know how to build the string
textView.text = context.getString(R.string.greeting, userName, messageCount)
```
With **TextResource**, you can pass the ready-to-resolve object instead:
```kotlin
// With TextResource
// ViewModel exposes the final representation
val greeting = TextResource.simple(R.string.greeting, "Derek", 3)

// UI just resolves it when needed
textView.text = greeting.resolveString(context)
```

### Example 2: Holding a Context in a ViewModel
A common anti-pattern is to hold a [Context] inside a ViewModel to build strings:
```kotlin
// Without TextResource
class MyViewModel(private val context: Context) : ViewModel() {
val greeting = context.getString(R.string.greeting, userName)
}
```
With **TextResource**, you just hold a `TextResource`:
```kotlin
// With TextResource
class MyViewModel : ViewModel() {
val greeting = TextResource.simple(R.string.greeting, "Derek", 3)
}

// Resolved later in the UI layer
textView.text = greeting.resolveString(context)
```
The UI (Activity/Fragment/Composable) provides the context at render time when resolving the string.

## Installation

Add the dependencies to your `build.gradle`:
Expand Down Expand Up @@ -72,7 +115,7 @@ val welcome = rememberTextResource(key1 = username) {
Text(welcome.resolveString())
```

### In a ViewModel
### ViewModel example
```kotlin
class HomeViewModel : ViewModel() {
private val _title = MutableStateFlow(TextResource.raw(""))
Expand All @@ -96,7 +139,7 @@ class HomeViewModel : ViewModel() {
}
```

### In Compose
### Compose example
```kotlin
@Composable
fun HomeScreen(vm: HomeViewModel = viewModel()) {
Expand Down Expand Up @@ -170,3 +213,26 @@ fun rememberTextResource(vararg keys: Any?, factory: () -> TextResource): TextRe
## License

This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.

## FAQ

**Q: Should I use the factory functions or the functional interface (SAM) initializer?**
A: Use the factory functions (`raw`, `simple`, `plural`) in most cases. These return **value-based objects** that:
- Compare equal when constructed with the same inputs (`==` works as expected).
- Work well in collections (`List`, `Set`, `Map`).
- Are easier to test and reason about.

The SAM initializer (`TextResource { ... }`) creates an anonymous object. Each call produces a new instance, so:
- Equality is by reference only (two identical SAMs are *not* equal).
- Collections treat them as different objects, even if they resolve to the same text.
- Use SAMs when you need dynamic/custom resolution logic.

```kotlin
val a = TextResource.simple(R.string.greeting, "Derek")
val b = TextResource.simple(R.string.greeting, "Derek")
println(a == b) // true ✅ value-based

val x = TextResource { "Hello, Derek" }
val y = TextResource { "Hello, Derek" }
println(x == y) // false ❌ reference-based
```
1 change: 1 addition & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,5 @@ dependencies {
testImplementation(libs.junit4)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(kotlin("test"))
}
1 change: 1 addition & 0 deletions core/src/debug/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<resources>
<string name="hello_user">Hello %1$s</string>
<string name="hello_friends">Hello %1$s and %2$s</string>
<plurals name="apples_count">
<item quantity="one">%1$d apple</item>
<item quantity="other">%1$d apples</item>
Expand Down
107 changes: 96 additions & 11 deletions core/src/main/java/io/github/dkmarkell/textresource/TextResource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ import androidx.annotation.StringRes
* ```
* val greeting = TextResource.simple(R.string.greeting, userName)
* ```
*
* ### Equality & identity
* Instances created via the **factory functions** (`raw`, `simple`, `plural`) are backed by
* private data classes and therefore have **value-based equality and stable hash codes**.
* This makes them safe to use as keys (e.g., in `remember(key = …)`, `distinctUntilChanged()`,
* `Set`/`Map`, DiffUtil, etc.).
*
* Instances created via the **SAM initializer**:
* ```
* val custom = TextResource { ctx -> "Hello, ${ctx.getString(R.string.app_name)}" }
* ```
* are anonymous implementations with **reference equality** (two instances are equal only if
* they are the same object). Prefer the factory functions whenever you plan to compare,
* deduplicate, or cache `TextResource` instances.
*/
public fun interface TextResource {
/**
Expand All @@ -49,16 +63,26 @@ public fun interface TextResource {
/**
* Creates a [TextResource] from a raw, non-localized [String].
*
* @param value The exact string to return when [resolveString] is called.
* - Returns a **value-equal, comparable** instance (backed by a private data class).
* Prefer this over the SAM initializer if you need equality or hashing semantics.
*
* @param text The exact string to return when [resolveString] is called.
* @return A [TextResource] that resolves to the given raw string.
*/
@JvmStatic
public fun raw(value: String): TextResource = TextResource { value }
public fun raw(text: String): TextResource {
return Raw(text = text)
}

/**
* Creates a [TextResource] from a string resource.
*
* - Returns a **value-equal, comparable** instance (backed by a private data class).
* Prefer this over the SAM initializer if you need equality or hashing semantics.
*
* @param resId String resource ID from your app/module.
* @param args Optional formatting arguments passed to [Context.getString].
* @return A [TextResource] that resolves via [Context.getString] for the given resource and arguments.
*
* ### Examples
* ```
Expand All @@ -67,28 +91,89 @@ public fun interface TextResource {
* ```
*/
@JvmStatic
public fun simple(@StringRes resId: Int, vararg args: Any): TextResource =
TextResource { context ->
context.getString(resId, *args)
}
public fun simple(@StringRes resId: Int, vararg args: Any): TextResource {
return Simple(resId = resId, args = args.toList())
}

/**
* Creates a [TextResource] from a plurals resource.
*
* - Returns a **value-equal, comparable** instance (backed by a private data class).
* Prefer this over the SAM initializer if you need equality or hashing semantics.
*
* @param resId Plurals resource ID from your app/module.
* @param quantity The count used to select the plural form.
* @param args Optional formatting arguments passed to
* [android.content.res.Resources.getQuantityString].
* @return A [TextResource] that resolves via
* [android.content.res.Resources.getQuantityString] for the given resource, quantity, and arguments.
*
* ### Example
* ```
* val apples = TextResource.plural(R.plurals.apple_count, count, count)
* ```
*/
@JvmStatic
public fun plural(@PluralsRes resId: Int, quantity: Int, vararg args: Any): TextResource =
TextResource { context ->
context.resources.getQuantityString(resId, quantity, *args)
}
public fun plural(@PluralsRes resId: Int, quantity: Int, vararg args: Any): TextResource {
return Plural(resId = resId, quantity = quantity, args = args.toList())
}
}
}
}

/**
* Private value type backing [TextResource.raw].
*
* - **Immutability:** stores a snapshot `String`.
* - **Equality:** value-based; two [Raw] instances are equal if their [text] is equal.
* - **Why factory:** prefer constructing via [TextResource.raw] so callers get stable
* equality semantics; the SAM initializer would not provide that.
*
* @property text Exact string returned by [resolveString].
*/
private data class Raw(val text: String) : TextResource {
override fun resolveString(context: Context): String {
return text
}
}


/**
* Private value type backing [TextResource.simple].
*
* - **Immutability:** stores [resId] and an immutable [args] list (created at the factory).
* - **Equality:** value-based across [resId] and [args]; safe for use in `remember` keys,
* `distinctUntilChanged`, and as map/set keys.
* - **Resolution:** delegates to [Context.getString] with the provided arguments.
* - **Why factory:** SAM implementations have reference equality only; this class gives
* structural equality for deduping and caching.
*
* @property resId String resource ID.
* @property args Formatting arguments captured as an immutable list.
*/
private data class Simple(@StringRes val resId: Int, val args: List<Any>) : TextResource {
override fun resolveString(context: Context): String {
return context.getString(resId, *args.toTypedArray())
}
}

/**
* Private value type backing [TextResource.plural].
*
* - **Immutability:** stores [resId], [quantity], and an immutable [args] list.
* - **Equality:** value-based across [resId], [quantity], and [args]; safe for use in
* `remember` keys, `distinctUntilChanged`, and as map/set keys.
* - **Resolution:** delegates to [android.content.res.Resources.getQuantityString].
* - **Why factory:** SAM implementations compare by reference; this class enables correct
* structural equality for collections/deduplication.
*
* @property resId Plurals resource ID.
* @property quantity Quantity used to select the plural form.
* @property args Formatting arguments captured as an immutable list.
*/
private data class Plural(@PluralsRes val resId: Int, val quantity: Int, val args: List<Any>) :
TextResource {
override fun resolveString(context: Context): String {
return context.resources.getQuantityString(resId, quantity, *args.toTypedArray())
}
}

Loading
Loading