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:
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user