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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
This commit is contained in:
2026-09-09 08:47:35 +03:00
co-authored by Claude Sonnet 5
parent 1a16f96739
commit f21bdd48f8
5 changed files with 108 additions and 5 deletions
+8 -1
View File
@@ -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
+7 -2
View File
@@ -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
}
}
@@ -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<String, List<String>> = 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()}"
}
@@ -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()
@@ -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"))
}
}