From 852951436002662ab33dda690a6becd27689a3e9 Mon Sep 17 00:00:00 2001 From: Derek Markell Date: Mon, 18 Aug 2025 17:13:06 -0400 Subject: [PATCH 1/3] Add private data classes backing the factory functions --- .../dkmarkell/textresource/TextResource.kt | 107 ++++++++++++++++-- 1 file changed, 96 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/io/github/dkmarkell/textresource/TextResource.kt b/core/src/main/java/io/github/dkmarkell/textresource/TextResource.kt index e5e9ff3..afbb39a 100644 --- a/core/src/main/java/io/github/dkmarkell/textresource/TextResource.kt +++ b/core/src/main/java/io/github/dkmarkell/textresource/TextResource.kt @@ -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 { /** @@ -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 * ``` @@ -67,18 +91,22 @@ 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 * ``` @@ -86,9 +114,66 @@ public fun interface TextResource { * ``` */ @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()) + } } -} \ No newline at end of file +} + +/** + * 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) : 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) : + TextResource { + override fun resolveString(context: Context): String { + return context.resources.getQuantityString(resId, quantity, *args.toTypedArray()) + } +} + From c2528ba0747d69fe39c60c662863e48cb3a1659c Mon Sep 17 00:00:00 2001 From: Derek Markell Date: Mon, 18 Aug 2025 17:33:13 -0400 Subject: [PATCH 2/3] Add more tests --- core/build.gradle.kts | 1 + core/src/debug/res/values/strings.xml | 1 + .../textresource/TextResourceTest.kt | 131 ++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 1a9a682..1df4775 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -107,4 +107,5 @@ dependencies { testImplementation(libs.junit4) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) + testImplementation(kotlin("test")) } diff --git a/core/src/debug/res/values/strings.xml b/core/src/debug/res/values/strings.xml index 09a4a25..9673574 100644 --- a/core/src/debug/res/values/strings.xml +++ b/core/src/debug/res/values/strings.xml @@ -1,5 +1,6 @@ Hello %1$s + Hello %1$s and %2$s %1$d apple %1$d apples diff --git a/core/src/test/java/io/github/dkmarkell/textresource/TextResourceTest.kt b/core/src/test/java/io/github/dkmarkell/textresource/TextResourceTest.kt index e12c34e..c1bd392 100644 --- a/core/src/test/java/io/github/dkmarkell/textresource/TextResourceTest.kt +++ b/core/src/test/java/io/github/dkmarkell/textresource/TextResourceTest.kt @@ -3,6 +3,9 @@ package io.github.dkmarkell.textresource import android.content.Context import androidx.test.core.app.ApplicationProvider import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -64,4 +67,132 @@ class TextResourceTest { // Then assertEquals("Hello Dev", result) } + + @Test + fun givenTwoRawWithSameValue_whenCompared_thenAreEqual() { + // Given + val a = TextResource.raw("hi") + val b = TextResource.raw("hi") + + // When / Then + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun givenTwoRawWithDifferentValue_whenCompared_thenAreNotEqual() { + // Given + val a = TextResource.raw("hi") + val b = TextResource.raw("bye") + + // When / Then + assertNotEquals(a, b) + } + + @Test + fun givenTwoSimpleWithSameResAndArgs_whenCompared_thenAreEqual() { + // Given + val a = TextResource.simple(R.string.hello_friends, "Derek", "Jim") + val b = TextResource.simple(R.string.hello_friends, "Derek", "Jim") + + // When / Then + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun givenTwoSimpleWithDifferentArgs_whenCompared_thenAreNotEqual() { + // Given + val a = TextResource.simple(R.string.hello_friends, "Derek", "Jim") + val b = TextResource.simple(R.string.hello_friends, "Derek", "Bob") + + // When / Then + assertNotEquals(a, b) + } + + @Test + fun givenTwoPluralWithSameValues_whenCompared_thenAreEqual() { + // Given + val a = TextResource.plural(R.plurals.apples_count, 2, 2) + val b = TextResource.plural(R.plurals.apples_count, 2, 2) + + // When / Then + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun givenTwoPluralWithDifferentQuantity_whenCompared_thenAreNotEqual() { + // Given + val a = TextResource.plural(R.plurals.apples_count, 2, 2) + val b = TextResource.plural(R.plurals.apples_count, 3, 3) + + // When / Then + assertNotEquals(a, b) + } + + @Test + fun givenTwoSimpleWithDifferentArgOrder_whenCompared_thenAreNotEqual() { + // Given + val a = TextResource.simple(R.string.hello_friends, "A", "B") + val b = TextResource.simple(R.string.hello_friends, "B", "A") + + // When / Then + assertNotEquals(a, b) + } + + @Test + fun givenFactoriesAndSam_whenCompared_thenFactoriesEqualButSamNotEqual() { + // Given + val f1 = TextResource.simple(R.string.hello_user, "Derek") + val f2 = TextResource.simple(R.string.hello_user, "Derek") + + val s1 = TextResource { "hi" } + val s2 = TextResource { "hi" } + + // When / Then + assertEquals(f1, f2) // data-class equality + assertNotEquals(s1, s2) // SAM reference equality + } + + @Test + fun givenFactoryInstances_whenUsedInList_thenBehaveByValue() { + // Given + val a = TextResource.simple(R.string.hello_user, "Derek") + val b = TextResource.simple(R.string.hello_user, "Derek") + + // When + val list = listOf(a) + + // Then + assertTrue(list.contains(b)) // passes because equals() works by value + } + + @Test + fun givenVarargMutationAfterCreation_whenCompared_thenEqualityUnchanged() { + // Given + val arr = arrayOf("A") + val tr = TextResource.simple(R.string.hello_user, *arr) + + // When + arr[0] = "B" // mutate original array + val expected = TextResource.simple(R.string.hello_user, "A") + + // Then + assertEquals(expected, tr) // still equal, because args were copied into a List + } + + @Test + fun givenList_whenContainsSamWithSameLogic_thenDoesNotContain() { + // Given + val sam1 = TextResource { "Hello, Derek" } // SAM (anonymous class) + val sam2 = TextResource { "Hello, Derek" } // another SAM instance + + val list = listOf(sam1) + + // When / Then + // SAMs compare by reference, so only the *same* instance is contained. + assertTrue(list.contains(sam1)) + assertFalse(list.contains(sam2)) + } } From ae2f3e7f139b80338e39769a6c300702247ebb60 Mon Sep 17 00:00:00 2001 From: Derek Markell Date: Tue, 19 Aug 2025 08:25:40 -0400 Subject: [PATCH 3/3] Update readme --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b1471ec..f5ea90a 100644 --- a/README.md +++ b/README.md @@ -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`: @@ -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("")) @@ -96,7 +139,7 @@ class HomeViewModel : ViewModel() { } ``` -### In Compose +### Compose example ```kotlin @Composable fun HomeScreen(vm: HomeViewModel = viewModel()) { @@ -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 +```