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")) + } +}