From bb4602e8fa626ba0f3310d1aa9bc2230128ec776 Mon Sep 17 00:00:00 2001 From: Terry Yiu Date: Thu, 10 Sep 2026 13:21:24 +0300 Subject: [PATCH] Resolve "EU" through platform locale data instead of hardcoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified directly (java.util.Locale, NSLocale, and Intl.DisplayNames all tested outside the app) that every platform's locale data resolves "EU" correctly on its own — "European Union" in en, "Union européenne" in fr, "Europäische Union" in de, etc. — via CLDR, which defines "EU" as a grouping in its own right despite it not being a real ISO 3166-1 country code. The hardcoded English fallback was based on an assumption that was never actually verified. Removes the special case from the shared code entirely. That required two platform-side fixes to stop blocking "EU" from ever reaching the platform lookup: - JVM/Android: dropped the Locale.getISOCountries() membership guard (which excluded "EU") in favor of detecting an unresolvable code by getDisplayCountry() echoing it back unchanged. - Web: jsRegionDisplayName() was hardcoding locale 'en' for Intl.DisplayNames — changed to undefined (the browser's own locale), matching what the other platforms already did. Without this fix, "EU" would resolve, but only ever in English on web. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy --- .../satsprice/SystemCurrencies.android.kt | 10 +++++---- .../xyz/tyiu/satsprice/SystemCurrencies.kt | 22 +++++++++---------- .../tyiu/satsprice/SystemCurrenciesTest.kt | 15 ++++++++----- .../tyiu/satsprice/SystemCurrencies.jvm.kt | 10 +++++---- .../tyiu/satsprice/SystemCurrencies.web.kt | 10 +++++---- 5 files changed, 38 insertions(+), 29 deletions(-) diff --git a/shared/src/androidMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.android.kt b/shared/src/androidMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.android.kt index 6f0878e..60d8db2 100644 --- a/shared/src/androidMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.android.kt +++ b/shared/src/androidMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.android.kt @@ -38,10 +38,12 @@ actual fun currencyDecimalDigits(code: String): Int = try { 2 } +// Doesn't gate on Locale.getISOCountries() — that would incorrectly exclude "EU", which isn't a +// real ISO 3166-1 code but which getDisplayCountry() resolves correctly anyway (CLDR defines it +// as a grouping in its own right). A genuinely-unresolvable code's display name just echoes the +// code back unchanged, so that's the signal used to report "unknown" instead. @Suppress("DEPRECATION") // Locale(language, country) still works fine; Locale.of() needs newer Android API levels. -actual fun regionDisplayName(regionCode: String): String? { - if (regionCode !in Locale.getISOCountries()) return null - return Locale("", regionCode).getDisplayCountry(Locale.getDefault()) -} +actual fun regionDisplayName(regionCode: String): String? = + Locale("", regionCode).getDisplayCountry(Locale.getDefault()).takeIf { it != regionCode } actual fun supportsFlagEmoji(): Boolean = true diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt index bf029a1..a2da5e8 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt @@ -13,7 +13,7 @@ fun matchesCurrencySearch(info: CurrencyInfo, query: String): Boolean { if (info.code.contains(query, ignoreCase = true)) return true if (info.displayName.contains(query, ignoreCase = true)) return true return issuingCountryCodes(info.code).any { regionCode -> - localizedRegionName(regionCode)?.contains(query, ignoreCase = true) == true + cachedRegionDisplayName(regionCode)?.contains(query, ignoreCase = true) == true } } @@ -24,20 +24,20 @@ fun matchesCurrencySearch(info: CurrencyInfo, query: String): Boolean { // being retried every time, which getOrPut would do for a null value. private val regionDisplayNameCache = mutableMapOf() -/** - * A localized display name for ISO 3166-1 alpha-2 [regionCode] (e.g. "Canada"), or null if the - * platform doesn't recognize it. "EU" is handled directly since it's not a real ISO 3166-1 - * country code — it's [issuingCountryCodes]' own stand-in for EUR's region/flag — and platform - * locale data doesn't reliably resolve it to a name the way it does real country codes. - */ -private fun localizedRegionName(regionCode: String): String? { +private fun cachedRegionDisplayName(regionCode: String): String? { if (regionCode in regionDisplayNameCache) return regionDisplayNameCache[regionCode] - val name = if (regionCode == "EU") "European Union" else regionDisplayName(regionCode) + val name = regionDisplayName(regionCode) regionDisplayNameCache[regionCode] = name return name } -/** The current platform's localized display name for ISO 3166-1 alpha-2 [regionCode], if known. */ +/** + * The current platform's localized display name for ISO 3166-1 alpha-2 [regionCode], if known. + * Also handles "EU" — [issuingCountryCodes]' own stand-in for EUR's region/flag, and not a real + * ISO 3166-1 code — since every platform's locale data resolves it correctly regardless (e.g. + * "European Union" in en, "Union européenne" in fr): CLDR, which all of their locale data is + * ultimately sourced from, defines "EU" as a grouping in its own right. + */ expect fun regionDisplayName(regionCode: String): String? /** @@ -52,7 +52,7 @@ fun warmRegionDisplayNameCache(currencies: List) { currencies.asSequence() .flatMap { issuingCountryCodes(it.code).asSequence() } .distinct() - .forEach { regionCode -> localizedRegionName(regionCode) } + .forEach { regionCode -> cachedRegionDisplayName(regionCode) } } /** diff --git a/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt b/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt index 751f1a3..e025c4b 100644 --- a/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt +++ b/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt @@ -51,13 +51,16 @@ class SystemCurrenciesTest { } @Test - fun matchesCurrencySearch_matchesEurozoneByHardcodedEuropeanUnionName() { - // "EU" isn't a real ISO 3166-1 country code, so platform locale data can't be relied on - // to name it — this is hardcoded rather than delegated to [regionDisplayName]. + fun matchesCurrencySearch_matchesEurozoneByEuropeanUnionName() { + // "EU" isn't a real ISO 3166-1 country code, but every platform's locale data resolves it + // correctly anyway (CLDR defines it as a grouping in its own right) — regionDisplayName() + // isn't special-cased for it. val eur = CurrencyInfo("EUR", "Euro") + val euName = regionDisplayName("EU") + assertTrue(euName != null && euName.isNotBlank(), "expected a display name for EU") - assertTrue(matchesCurrencySearch(eur, "European")) - assertTrue(matchesCurrencySearch(eur, "union")) - assertFalse(matchesCurrencySearch(eur, "Germany")) + assertTrue(matchesCurrencySearch(eur, euName)) + assertTrue(matchesCurrencySearch(eur, euName.lowercase())) + assertFalse(matchesCurrencySearch(eur, "Definitely not a matching country name")) } } diff --git a/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.jvm.kt b/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.jvm.kt index 6f0878e..60d8db2 100644 --- a/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.jvm.kt +++ b/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.jvm.kt @@ -38,10 +38,12 @@ actual fun currencyDecimalDigits(code: String): Int = try { 2 } +// Doesn't gate on Locale.getISOCountries() — that would incorrectly exclude "EU", which isn't a +// real ISO 3166-1 code but which getDisplayCountry() resolves correctly anyway (CLDR defines it +// as a grouping in its own right). A genuinely-unresolvable code's display name just echoes the +// code back unchanged, so that's the signal used to report "unknown" instead. @Suppress("DEPRECATION") // Locale(language, country) still works fine; Locale.of() needs newer Android API levels. -actual fun regionDisplayName(regionCode: String): String? { - if (regionCode !in Locale.getISOCountries()) return null - return Locale("", regionCode).getDisplayCountry(Locale.getDefault()) -} +actual fun regionDisplayName(regionCode: String): String? = + Locale("", regionCode).getDisplayCountry(Locale.getDefault()).takeIf { it != regionCode } actual fun supportsFlagEmoji(): Boolean = true diff --git a/shared/src/webMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.web.kt b/shared/src/webMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.web.kt index 092b331..a3cdcce 100644 --- a/shared/src/webMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.web.kt +++ b/shared/src/webMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.web.kt @@ -37,13 +37,15 @@ actual fun currencyDecimalDigits(code: String): Int = try { 2 } -// Falls back to the region code itself (rather than a JS null/undefined) when unrecognized, since -// a plain `-> String?` return type doesn't reliably round-trip through js() interop here; that -// fallback is filtered back out to null actual-side below, same as the other platforms. +// `undefined` (rather than a fixed locale like 'en') uses the browser's own locale, matching the +// other platforms' regionDisplayName(). Falls back to the region code itself (rather than a JS +// null/undefined) when unrecognized, since a plain `-> String?` return type doesn't reliably +// round-trip through js() interop here; that fallback is filtered back out to null actual-side +// below, same as the other platforms. private fun jsRegionDisplayName(regionCode: String): String = js( """(function() { try { - var names = new Intl.DisplayNames(['en'], { type: 'region' }); + var names = new Intl.DisplayNames(undefined, { type: 'region' }); return names.of(regionCode) || regionCode; } catch (e) { return regionCode;