From f21bdd48f839255d734ec48484416f3da0fde3ba Mon Sep 17 00:00:00 2001 From: Terry Yiu Date: Wed, 9 Sep 2026 08:47:35 +0300 Subject: [PATCH] Show a flag emoji next to each currency code Derives a country flag from the ISO 4217 code's country prefix, with an explicit map of country codes for currencies shared by multiple countries (XAF, XCD, XCG, XOF, XPF) showing every issuing country's flag, and the EU flag as a special case for EUR. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp --- iosApp/iosApp/ContentView.swift | 9 +++- iosApp/iosApp/CurrencyPickerSheet.swift | 9 +++- .../kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt | 45 +++++++++++++++++++ .../xyz/tyiu/satsprice/ui/PriceScreen.kt | 7 ++- .../xyz/tyiu/satsprice/CurrencyFlagTest.kt | 43 ++++++++++++++++++ 5 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt create mode 100644 shared/src/commonTest/kotlin/xyz/tyiu/satsprice/CurrencyFlagTest.kt diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index b474f52..89d81da 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -124,7 +124,7 @@ struct ContentView: View { ForEach(Array(state.fiatRows.enumerated()), id: \.element.code) { index, row in amountRow( - label: row.code, + label: currencyFieldLabel(for: row.code), value: row.amount, keyboardType: .decimalPad, sanitize: sanitizeDecimalInput, @@ -311,6 +311,13 @@ private struct NumericField: View { } } +private func currencyFieldLabel(for code: String) -> String { + if let flag = CurrencyFlagKt.currencyFlagEmoji(code: code) { + return "\(flag) \(code)" + } + return code +} + /// Unlike the shared Kotlin `sanitizeDecimalInput` (which never sees grouping separators, since /// Compose's VisualTransformation never feeds its grouped display back into the real value), /// `NumericField`'s single `text` buffer *is* the grouped display, re-fed through this on every diff --git a/iosApp/iosApp/CurrencyPickerSheet.swift b/iosApp/iosApp/CurrencyPickerSheet.swift index 8228af1..ddc4d2e 100644 --- a/iosApp/iosApp/CurrencyPickerSheet.swift +++ b/iosApp/iosApp/CurrencyPickerSheet.swift @@ -81,16 +81,21 @@ struct CurrencyPickerSheet: View { } private func currencyLabel(for info: CurrencyInfo) -> String { + let text: String if info.code == localeCurrencyCode { - return IosLocalizationKt.localizedFormattedString( + text = IosLocalizationKt.localizedFormattedString( resource: MR.strings.shared.currency_option_label_local, args: [info.code, info.displayName] ) } else { - return IosLocalizationKt.localizedFormattedString( + text = IosLocalizationKt.localizedFormattedString( resource: MR.strings.shared.currency_option_label, args: [info.code, info.displayName] ) } + if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code) { + return "\(flag) \(text)" + } + return text } } diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt new file mode 100644 index 0000000..bb322b2 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt @@ -0,0 +1,45 @@ +package xyz.tyiu.satsprice + +/** + * ISO 3166-1 alpha-2 codes of every country that issues each ISO 4217 currency code shared by + * multiple countries with no single issuer, so all of their flags can be shown side by side. + */ +private val MULTI_COUNTRY_CURRENCY_REGION_CODES: Map> = mapOf( + "XAF" to listOf("CF", "CG", "CM", "GA", "GQ", "TD"), + "XCD" to listOf("AG", "AI", "DM", "GD", "KN", "LC", "MS", "VC"), + "XCG" to listOf("CW", "SX"), + "XOF" to listOf("BF", "BJ", "CI", "GW", "ML", "NE", "SN", "TG"), + "XPF" to listOf("NC", "PF", "WF"), +) + +/** + * 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; 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. + */ +fun currencyFlagEmoji(code: String): String? { + MULTI_COUNTRY_CURRENCY_REGION_CODES[code]?.let { regionCodes -> + 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) +} + +/** The flag emoji for the 2-letter ISO 3166-1 alpha-2 [regionCode]. */ +private fun regionFlagEmoji(regionCode: String): String = + regionCode.map { regionalIndicatorSymbol(it) }.joinToString("") + +/** The Unicode Regional Indicator Symbol for [letter] ('A'..'Z'), as a surrogate pair. */ +private fun regionalIndicatorSymbol(letter: Char): String { + val codePoint = 0x1F1E6 + (letter - 'A') + val offset = codePoint - 0x10000 + val high = 0xD800 + (offset shr 10) + val low = 0xDC00 + (offset and 0x3FF) + return "${high.toChar()}${low.toChar()}" +} diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt index 6301a84..07f2cd2 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt @@ -65,6 +65,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import dev.icerock.moko.resources.compose.stringResource import xyz.tyiu.satsprice.CurrencyInfo +import xyz.tyiu.satsprice.currencyFlagEmoji import xyz.tyiu.satsprice.domain.CurrencyConverter import xyz.tyiu.satsprice.domain.formatAmount import xyz.tyiu.satsprice.domain.groupDigits @@ -329,10 +330,11 @@ fun PriceScreen( verticalAlignment = Alignment.CenterVertically, ) { val isPriced = state.isPriced(code) + val fieldLabel = currencyFlagEmoji(code)?.let { flag -> "$flag $code" } ?: code OutlinedTextField( value = if (isPriced) state.fiatAmounts[code].orEmpty() else "", onValueChange = { viewModel.onFiatAmountChanged(code, it) }, - label = { Text(code) }, + label = { Text(fieldLabel) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, enabled = isPriced, @@ -561,11 +563,12 @@ private fun CurrencyRow( localeCurrencyCode: String?, onClick: (() -> Unit)?, ) { - val label = if (info.code == localeCurrencyCode) { + val text = if (info.code == localeCurrencyCode) { stringResource(MR.strings.currency_option_label_local, info.code, info.displayName) } else { stringResource(MR.strings.currency_option_label, info.code, info.displayName) } + val label = currencyFlagEmoji(info.code)?.let { flag -> "$flag $text" } ?: text Row( modifier = Modifier .fillMaxWidth() diff --git a/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/CurrencyFlagTest.kt b/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/CurrencyFlagTest.kt new file mode 100644 index 0000000..e1898c0 --- /dev/null +++ b/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/CurrencyFlagTest.kt @@ -0,0 +1,43 @@ +package xyz.tyiu.satsprice + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CurrencyFlagTest { + + @Test + fun currencyFlagEmoji_derivesFlagFromCountryPrefix() { + assertEquals("🇺🇸", currencyFlagEmoji("USD")) // US + assertEquals("🇬🇧", currencyFlagEmoji("GBP")) // GB + assertEquals("🇯🇵", currencyFlagEmoji("JPY")) // JP + } + + @Test + fun currencyFlagEmoji_usesEuFlagForEuro() { + assertEquals("🇪🇺", currencyFlagEmoji("EUR")) // EU + } + + @Test + fun currencyFlagEmoji_showsAllFlagsForCurrenciesSharedByMultipleCountries() { + assertEquals("🇦🇬 🇦🇮 🇩🇲 🇬🇩 🇰🇳 🇱🇨 🇲🇸 🇻🇨", currencyFlagEmoji("XCD")) + assertEquals("🇧🇫 🇧🇯 🇨🇮 🇬🇼 🇲🇱 🇳🇪 🇸🇳 🇹🇬", currencyFlagEmoji("XOF")) + assertEquals("🇨🇫 🇨🇬 🇨🇲 🇬🇦 🇬🇶 🇹🇩", currencyFlagEmoji("XAF")) + assertEquals("🇳🇨 🇵🇫 🇼🇫", currencyFlagEmoji("XPF")) + } + + @Test + fun currencyFlagEmoji_returnsNullForNonNationalCodes() { + // ISO 4217 reserves the remaining "X"-prefixed codes for precious metals, testing codes, + // and other non-national codes with no country to show. + assertNull(currencyFlagEmoji("XAU")) + assertNull(currencyFlagEmoji("XXX")) + } + + @Test + fun currencyFlagEmoji_returnsNullForInvalidCodes() { + assertNull(currencyFlagEmoji("")) + assertNull(currencyFlagEmoji("U")) + assertNull(currencyFlagEmoji("123")) + } +}