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 10264b1..3a19597 100644 --- a/shared/src/androidMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.android.kt +++ b/shared/src/androidMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.android.kt @@ -37,3 +37,9 @@ actual fun currencyDecimalDigits(code: String): Int = try { } catch (e: IllegalArgumentException) { 2 } + +@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()) +} diff --git a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.apple.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.apple.kt index 41dd0a3..ef123ec 100644 --- a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.apple.kt +++ b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.apple.kt @@ -34,3 +34,6 @@ actual fun currencyDecimalDigits(code: String): Int { formatter.currencyCode = code return formatter.maximumFractionDigits.toInt() } + +actual fun regionDisplayName(regionCode: String): String? = + NSLocale.currentLocale.localizedStringForCountryCode(regionCode) diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt index 9836323..0d21955 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt @@ -16,24 +16,31 @@ private val MULTI_COUNTRY_CURRENCY_REGION_CODES: Map> = map private const val MAX_FLAGS_PER_CURRENCY = 3 /** - * A country flag emoji for [code], derived from the first two letters of the ISO 4217 code — - * which double as the issuing country's ISO 3166-1 alpha-2 code for ordinary national - * currencies — or null when there's no flag to show. [MULTI_COUNTRY_CURRENCY_REGION_CODES] - * lists every country sharing a currency with no single issuer, so those show all their flags - * side by side, unless there are more than [MAX_FLAGS_PER_CURRENCY] of them; the remaining - * "X"-prefixed codes are precious metals, testing codes, and other non-national codes (XAU, XTS, - * XXX, ...), none of which has a country to show. EUR is a special case handled separately, using - * the EU's own flag. + * ISO 3166-1 alpha-2 codes of every country that issues [code], derived from the first two + * letters of the ISO 4217 code — which double as the issuing country's ISO 3166-1 alpha-2 code + * for ordinary national currencies — or empty when [code] isn't tied to any country. + * [MULTI_COUNTRY_CURRENCY_REGION_CODES] lists every country sharing a currency with no single + * issuer; the remaining "X"-prefixed codes are precious metals, testing codes, and other + * non-national codes (XAU, XTS, XXX, ...), none of which has a country. EUR is a special case, + * using the EU's own region code. Shared by [currencyFlagEmoji] and currency search-by-country. + */ +internal fun issuingCountryCodes(code: String): List { + MULTI_COUNTRY_CURRENCY_REGION_CODES[code]?.let { return it } + if (code.startsWith("X")) return emptyList() + val regionCode = if (code == "EUR") "EU" else code.take(2) + if (regionCode.length != 2 || regionCode.any { it !in 'A'..'Z' }) return emptyList() + return listOf(regionCode) +} + +/** + * A country flag emoji for [code], or null when there's no flag to show — either because [code] + * isn't tied to any country, or because it's shared by more than [MAX_FLAGS_PER_CURRENCY] + * countries, which would be too visually noisy to show side by side. */ fun currencyFlagEmoji(code: String): String? { - MULTI_COUNTRY_CURRENCY_REGION_CODES[code]?.let { regionCodes -> - if (regionCodes.size > MAX_FLAGS_PER_CURRENCY) return null - return regionCodes.joinToString(" ") { regionFlagEmoji(it) } - } - if (code.startsWith("X")) return null - val regionCode = if (code == "EUR") "EU" else code.take(2) - if (regionCode.length != 2 || regionCode.any { it !in 'A'..'Z' }) return null - return regionFlagEmoji(regionCode) + val regionCodes = issuingCountryCodes(code) + if (regionCodes.isEmpty() || regionCodes.size > MAX_FLAGS_PER_CURRENCY) return null + return regionCodes.joinToString(" ") { regionFlagEmoji(it) } } /** The flag emoji for the 2-letter ISO 3166-1 alpha-2 [regionCode]. */ diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt index 95c3459..2ad23c5 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt @@ -3,14 +3,31 @@ package xyz.tyiu.satsprice data class CurrencyInfo(val code: String, val displayName: String) /** - * Whether [info]'s code or display name contains [query], case-insensitively. A plain function - * (rather than a `CurrencyInfo` extension) so Kotlin/Native exports a predictable, positionally - * clear Swift signature, matching [currencyFlagEmoji]'s style. + * Whether [info]'s code, display name, or the localized name of any country that issues it (e.g. + * "Japan" for JPY) contains [query], case-insensitively. A plain function (rather than a + * `CurrencyInfo` extension) so Kotlin/Native exports a predictable, positionally clear Swift + * signature, matching [currencyFlagEmoji]'s style. */ -fun matchesCurrencySearch(info: CurrencyInfo, query: String): Boolean = - query.isBlank() || - info.code.contains(query, ignoreCase = true) || - info.displayName.contains(query, ignoreCase = true) +fun matchesCurrencySearch(info: CurrencyInfo, query: String): Boolean { + if (query.isBlank()) return true + 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 + } +} + +/** + * 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? = + if (regionCode == "EU") "European Union" else regionDisplayName(regionCode) + +/** The current platform's localized display name for ISO 3166-1 alpha-2 [regionCode], if known. */ +expect fun regionDisplayName(regionCode: String): String? /** * ISO 4217 codes for the precious metals actively traded today. These aren't tied to any diff --git a/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt b/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt index ecacf10..751f1a3 100644 --- a/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt +++ b/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt @@ -36,4 +36,28 @@ class SystemCurrenciesTest { assertFalse(matchesCurrencySearch(usd, "EUR")) assertFalse(matchesCurrencySearch(usd, "Euro")) } + + @Test + fun matchesCurrencySearch_matchesByIssuingCountryName() { + // "Canadian Dollar" doesn't contain "Canada" as a substring, so this only passes if the + // country-name path (rather than just code/display-name matching) is actually consulted. + val cad = CurrencyInfo("CAD", "Canadian Dollar") + val countryName = regionDisplayName("CA") + assertTrue(countryName != null && countryName.isNotBlank(), "expected a display name for CA") + + assertTrue(matchesCurrencySearch(cad, countryName)) + assertTrue(matchesCurrencySearch(cad, countryName.lowercase())) + assertFalse(matchesCurrencySearch(cad, "Definitely not a matching country name")) + } + + @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]. + val eur = CurrencyInfo("EUR", "Euro") + + assertTrue(matchesCurrencySearch(eur, "European")) + assertTrue(matchesCurrencySearch(eur, "union")) + assertFalse(matchesCurrencySearch(eur, "Germany")) + } } 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 10264b1..3a19597 100644 --- a/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.jvm.kt +++ b/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.jvm.kt @@ -37,3 +37,9 @@ actual fun currencyDecimalDigits(code: String): Int = try { } catch (e: IllegalArgumentException) { 2 } + +@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()) +} 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 ce077b1..5b5cdc6 100644 --- a/shared/src/webMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.web.kt +++ b/shared/src/webMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.web.kt @@ -36,3 +36,20 @@ actual fun currencyDecimalDigits(code: String): Int = try { } catch (e: Exception) { 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. +private fun jsRegionDisplayName(regionCode: String): String = js( + """(function() { + try { + var names = new Intl.DisplayNames(['en'], { type: 'region' }); + return names.of(regionCode) || regionCode; + } catch (e) { + return regionCode; + } + })()""", +) + +actual fun regionDisplayName(regionCode: String): String? = + jsRegionDisplayName(regionCode).takeIf { it != regionCode }