diff --git a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt index 81869f75d..dda33e962 100644 --- a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt @@ -139,8 +139,8 @@ class WeatherService @Inject constructor( } private fun formatFeeForDisplay(sats: Long): String { - val usdValue = currencyRepo.convertSatsToFiat(sats, USD).getOrNull() - return usdValue?.formatted.orEmpty() + val selectedFiatValue = currencyRepo.convertSatsToFiat(sats).getOrNull() + return selectedFiatValue?.formattedWithSymbol(withSpace = true).orEmpty() } } diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index fb17d1adf..57af17d2b 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -111,9 +111,9 @@ class NotifyPaymentReceivedHandler @Inject constructor( val amountText = converted?.let { val btcDisplay = it.bitcoinDisplay(settings.displayUnit) if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) { - "${btcDisplay.symbol} ${btcDisplay.value} (${it.symbol}${it.formatted})" + "${btcDisplay.symbol} ${btcDisplay.value} (${it.formattedWithSymbol()})" } else { - "${it.symbol}${it.formatted} (${btcDisplay.symbol} ${btcDisplay.value})" + "${it.formattedWithSymbol()} (${btcDisplay.symbol} ${btcDisplay.value})" } } ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}" diff --git a/app/src/main/java/to/bitkit/models/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index 9d5256caa..b37612564 100644 --- a/app/src/main/java/to/bitkit/models/Currency.kt +++ b/app/src/main/java/to/bitkit/models/Currency.kt @@ -76,6 +76,8 @@ data class ConvertedAmount( val sats: Long, val locale: Locale = Locale.getDefault(), ) { + val isSymbolSuffix: Boolean get() = currency in SUFFIX_SYMBOL_CURRENCIES + data class BitcoinDisplayComponents( val symbol: String, val value: String, @@ -88,6 +90,12 @@ data class ConvertedAmount( value = formattedValue, ) } + + fun formattedWithSymbol(withSpace: Boolean = false): String = value.formatCurrencyWithSymbol( + currencyCode = currency, + currencySymbol = symbol, + withSpace = withSpace, + ) } fun Long.formatMoney( @@ -145,5 +153,42 @@ fun BigDecimal.formatCurrency(decimalPlaces: Int = FIAT_DECIMALS, locale: Locale return runCatching { formatter.format(this) }.getOrNull() } +fun BigDecimal.formatCurrencyWithSymbol( + currencyCode: String, + currencySymbol: String? = null, + withSpace: Boolean = false, + decimalPlaces: Int = FIAT_DECIMALS, +): String { + val formatted = formatCurrency(decimalPlaces) ?: "0.00" + val symbol = currencySymbol + ?: runCatching { java.util.Currency.getInstance(currencyCode) }.getOrNull()?.symbol + ?: currencyCode + val separator = if (withSpace) " " else "" + + return if (currencyCode in SUFFIX_SYMBOL_CURRENCIES) { + "$formatted$separator$symbol" + } else { + "$symbol$separator$formatted" + } +} + +fun isSuffixSymbolCurrency(currencyCode: String): Boolean = currencyCode in SUFFIX_SYMBOL_CURRENCIES + +private val SUFFIX_SYMBOL_CURRENCIES = setOf( + "BGN", + "CHF", + "CZK", + "DKK", + "HRK", + "HUF", + "ISK", + "NOK", + "PLN", + "RON", + "RUB", + "SEK", + "TRY", +) + /** Represent this sat value in Bitcoin BigDecimal. */ fun Long.asBtc(): BigDecimal = BigDecimal(this).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP) diff --git a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt index 565cbd41b..fe8505b24 100644 --- a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt +++ b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt @@ -89,10 +89,12 @@ fun BalanceHeaderView( modifier = modifier, smallRowSymbol = if (isBitcoinPrimary) fiat.symbol else btc.symbol, smallRowText = if (isBitcoinPrimary) fiat.formatted else btc.value, + smallRowIsSymbolSuffix = if (isBitcoinPrimary) fiat.isSymbolSuffix else false, smallRowModifier = Modifier.testTag("$testTag-secondary"), largeRowPrefix = prefix, largeRowText = if (isBitcoinPrimary) btc.value else fiat.formatted, largeRowSymbol = if (isBitcoinPrimary) btc.symbol else fiat.symbol, + largeRowIsSymbolSuffix = if (isBitcoinPrimary) false else fiat.isSymbolSuffix, largeRowModifier = Modifier.testTag("$testTag-primary"), showSymbol = if (isBitcoinPrimary) showBitcoinSymbol else true, hideBalance = shouldHideBalance, @@ -110,10 +112,12 @@ fun BalanceHeader( modifier: Modifier = Modifier, smallRowSymbol: String? = null, smallRowText: String, + smallRowIsSymbolSuffix: Boolean = false, smallRowModifier: Modifier = Modifier, largeRowPrefix: String? = null, largeRowText: String, largeRowSymbol: String, + largeRowIsSymbolSuffix: Boolean = false, largeRowModifier: Modifier = Modifier, showSymbol: Boolean, hideBalance: Boolean = false, @@ -137,6 +141,7 @@ fun BalanceHeader( SmallRow( symbol = smallRowSymbol, text = smallRowText, + isSymbolSuffix = smallRowIsSymbolSuffix, hideBalance = hideBalance, modifier = smallRowModifier, ) @@ -151,6 +156,7 @@ fun BalanceHeader( text = largeRowText, symbol = largeRowSymbol, showSymbol = showSymbol, + isSymbolSuffix = largeRowIsSymbolSuffix, hideBalance = hideBalance, modifier = largeRowModifier, ) @@ -187,6 +193,7 @@ fun LargeRow( symbol: String, showSymbol: Boolean, modifier: Modifier = Modifier, + isSymbolSuffix: Boolean = false, hideBalance: Boolean = false, ) { Row( @@ -202,7 +209,7 @@ fun LargeRow( .testTag("MoneySign") ) } - if (showSymbol) { + if (showSymbol && !isSymbolSuffix) { Display( text = symbol, color = Colors.White64, @@ -221,6 +228,15 @@ fun LargeRow( modifier = Modifier.testTag("MoneyText") ) } + if (showSymbol && isSymbolSuffix) { + Display( + text = symbol, + color = Colors.White64, + modifier = Modifier + .padding(start = 8.dp) + .testTag("MoneyFiatSymbol") + ) + } } } @@ -229,6 +245,7 @@ private fun SmallRow( symbol: String?, text: String, modifier: Modifier = Modifier, + isSymbolSuffix: Boolean = false, hideBalance: Boolean = false, ) { Row( @@ -236,7 +253,7 @@ private fun SmallRow( horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - if (symbol != null) { + if (symbol != null && !isSymbolSuffix) { Caption13Up( text = symbol, color = Colors.White64, @@ -254,6 +271,13 @@ private fun SmallRow( modifier = Modifier.testTag("MoneyText") ) } + if (symbol != null && isSymbolSuffix) { + Caption13Up( + text = symbol, + color = Colors.White64, + modifier = Modifier.testTag("MoneyFiatSymbol") + ) + } } } diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index cc863d7a1..920efa6c3 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -144,8 +144,9 @@ fun rememberMoneyText( } } else { buildString { - if (showSymbol) append("${converted.symbol} ") + if (showSymbol && !converted.isSymbolSuffix) append("${converted.symbol} ") append(converted.formatted) + if (showSymbol && converted.isSymbolSuffix) append("${converted.symbol}") } } } diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt index add65817c..b158ff18f 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt @@ -20,6 +20,7 @@ import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.PrimaryDisplay import to.bitkit.models.USD_SYMBOL import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.isSuffixSymbolCurrency import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.shared.modifiers.clickableAlpha @@ -48,6 +49,8 @@ fun NumberPadTextField( showPlaceholder = true, satoshis = uiState.value.sats, currencySymbol = currencies.currencySymbol, + isSymbolSuffix = currencies.primaryDisplay == PrimaryDisplay.FIAT && + isSuffixSymbolCurrency(currencies.selectedCurrency), showSecondaryField = showSecondaryField, ) } @@ -60,11 +63,14 @@ private fun MoneyAmount( satoshis: Long, modifier: Modifier = Modifier, currencySymbol: String = BITCOIN_SYMBOL, + isSymbolSuffix: Boolean = false, showPlaceholder: Boolean = true, showSecondaryField: Boolean = true, valueStyle: SpanStyle = SpanStyle(color = Colors.White), placeholderStyle: SpanStyle = SpanStyle(color = Colors.White50), ) { + val symbol = if (unit == PrimaryDisplay.BITCOIN) BITCOIN_SYMBOL else currencySymbol + Column( modifier = modifier.semantics { contentDescription = value }, horizontalAlignment = Alignment.Start @@ -76,11 +82,13 @@ private fun MoneyAmount( Row( verticalAlignment = Alignment.CenterVertically, ) { - Display( - text = if (unit == PrimaryDisplay.BITCOIN) BITCOIN_SYMBOL else currencySymbol, - color = Colors.White64, - modifier = Modifier.padding(end = 6.dp) - ) + if (!isSymbolSuffix) { + Display( + text = symbol, + color = Colors.White64, + modifier = Modifier.padding(end = 6.dp) + ) + } Display( text = buildAnnotatedString { if (value != placeholder) { @@ -95,6 +103,13 @@ private fun MoneyAmount( } } ) + if (isSymbolSuffix) { + Display( + text = symbol, + color = Colors.White64, + modifier = Modifier.padding(start = 6.dp) + ) + } } } } diff --git a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt index db8ca0a9b..f303de52d 100644 --- a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt +++ b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt @@ -146,8 +146,13 @@ private fun RowScope.Content( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - BodyMSB(text = converted.symbol) - BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else converted.formatted) + if (converted.isSymbolSuffix) { + BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else converted.formatted) + BodyMSB(text = converted.symbol) + } else { + BodyMSB(text = converted.symbol) + BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else converted.formatted) + } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index f9961bdd0..bd2b9ea85 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -245,6 +245,7 @@ private fun AmountView( titlePrefix = prefix, subtitle = converted.formatted, subtitleSymbol = converted.symbol, + isSymbolSuffix = converted.isSymbolSuffix, hideBalance = hideBalance, ) } else { @@ -253,6 +254,7 @@ private fun AmountView( titleSymbol = converted.symbol, titlePrefix = prefix, subtitle = btcValue, + isSymbolSuffix = converted.isSymbolSuffix, hideBalance = hideBalance, ) } @@ -267,6 +269,7 @@ private fun AmountViewContent( modifier: Modifier = Modifier, titleSymbol: String? = null, subtitleSymbol: String? = null, + isSymbolSuffix: Boolean = false, hideBalance: Boolean = false, ) { Column( @@ -280,7 +283,7 @@ private fun AmountViewContent( horizontalArrangement = Arrangement.spacedBy(1.dp), ) { BodyM(text = titlePrefix, color = Colors.White64) - if (titleSymbol != null) { + if (titleSymbol != null && !isSymbolSuffix) { BodyMSB(text = titleSymbol, color = Colors.White64) } Spacer(modifier = Modifier.width(2.dp)) @@ -291,6 +294,9 @@ private fun AmountViewContent( ) { isHidden -> BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else title) } + if (titleSymbol != null && isSymbolSuffix) { + BodyMSB(text = titleSymbol, color = Colors.White64) + } } // Subtitle row with static symbol @@ -298,7 +304,7 @@ private fun AmountViewContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp), ) { - if (subtitleSymbol != null) { + if (subtitleSymbol != null && !isSymbolSuffix) { CaptionB(text = subtitleSymbol, color = Colors.White64) } AnimatedContent( @@ -311,6 +317,9 @@ private fun AmountViewContent( color = Colors.White64, ) } + if (subtitleSymbol != null && isSymbolSuffix) { + CaptionB(text = subtitleSymbol, color = Colors.White64) + } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt index d1a93def0..9722288f3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt @@ -64,13 +64,13 @@ fun ReceiveConfirmScreen( val networkFeeFormatted = remember(entry.networkFeeSat) { currency.convert(entry.networkFeeSat) - ?.let { converted -> "${converted.symbol}${converted.formatted}" } + ?.let { converted -> converted.formattedWithSymbol() } ?: entry.networkFeeSat.toString() } val serviceFeeFormatted = remember(entry.serviceFeeSat) { currency.convert(entry.serviceFeeSat) - ?.let { converted -> "${converted.symbol}${converted.formatted}" } + ?.let { converted -> converted.formattedWithSymbol() } ?: entry.serviceFeeSat.toString() } @@ -84,7 +84,7 @@ fun ReceiveConfirmScreen( val btcComponents = converted.bitcoinDisplay(displayUnit) "${btcComponents.symbol} ${btcComponents.value}" } else { - "${converted.symbol} ${converted.formatted}" + converted.formattedWithSymbol() } } ?: sats.toString() } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt index 96b032de3..c27f20d2d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt @@ -213,7 +213,7 @@ private fun UtxoRow( currency.convert(sats = amount)?.let { converted -> val btcValue = converted.bitcoinDisplay(displayUnit).value BodyMSB(text = btcValue) - BodySSB(text = "${converted.symbol} ${converted.formatted}", color = Colors.White64) + BodySSB(text = converted.formattedWithSymbol(), color = Colors.White64) } } diff --git a/app/src/test/java/to/bitkit/models/CurrencyTest.kt b/app/src/test/java/to/bitkit/models/CurrencyTest.kt index 7b9ad3324..a79ced622 100644 --- a/app/src/test/java/to/bitkit/models/CurrencyTest.kt +++ b/app/src/test/java/to/bitkit/models/CurrencyTest.kt @@ -2,6 +2,7 @@ package to.bitkit.models import org.junit.Assert.assertEquals import org.junit.Test +import java.math.BigDecimal import java.util.Locale class CurrencyTest { @@ -37,4 +38,176 @@ class CurrencyTest { assertEquals("0.00012345", formatted) } + + @Test + fun `formatCurrencyWithSymbol places USD symbol before amount`() { + val value = BigDecimal("10.50") + + val formatted = value.formatCurrencyWithSymbol("USD") + + assertEquals("$10.50", formatted) + } + + @Test + fun `formatCurrencyWithSymbol places GBP symbol before amount`() { + val value = BigDecimal("10.50") + + val formatted = value.formatCurrencyWithSymbol("GBP") + + assertEquals("£10.50", formatted) + } + + @Test + fun `formatCurrencyWithSymbol places PLN symbol after amount`() { + val value = BigDecimal("0.35") + + val formatted = value.formatCurrencyWithSymbol("PLN", "zł") + + assertEquals("0.35zł", formatted) + } + + @Test + fun `formatCurrencyWithSymbol places EUR symbol before amount`() { + val value = BigDecimal("10.00") + + val formatted = value.formatCurrencyWithSymbol("EUR", "€") + + assertEquals("€10.00", formatted) + } + + @Test + fun `formatCurrencyWithSymbol places CZK symbol after amount`() { + val value = BigDecimal("250.00") + + val formatted = value.formatCurrencyWithSymbol("CZK", "Kč") + + assertEquals("250.00Kč", formatted) + } + + @Test + fun `formatCurrencyWithSymbol places SEK symbol after amount`() { + val value = BigDecimal("100.00") + + val formatted = value.formatCurrencyWithSymbol("SEK", "kr") + + assertEquals("100.00kr", formatted) + } + + @Test + fun `formatCurrencyWithSymbol places CHF symbol after amount`() { + val value = BigDecimal("50.00") + + val formatted = value.formatCurrencyWithSymbol("CHF", "CHF") + + assertEquals("50.00CHF", formatted) + } + + @Test + fun `formatCurrencyWithSymbol places USD symbol before amount with space`() { + val value = BigDecimal("10.50") + + val formatted = value.formatCurrencyWithSymbol( + currencyCode = "USD", + withSpace = true, + ) + + assertEquals("$ 10.50", formatted) + } + + @Test + fun `formatCurrencyWithSymbol places PLN symbol after amount with space`() { + val value = BigDecimal("0.35") + + val formatted = value.formatCurrencyWithSymbol( + currencyCode = "PLN", + currencySymbol = "zł", + withSpace = true, + ) + + assertEquals("0.35 zł", formatted) + } + + @Test + fun `formatCurrencyWithSymbol falls back to currency code when no symbol provided`() { + val value = BigDecimal("100.00") + + val formatted = value.formatCurrencyWithSymbol("PLN") + + // Without explicit symbol, falls back to Java Currency symbol (PLN in non-Polish locale) + assertEquals("100.00PLN", formatted) + } + + @Test + fun `formatCurrencyWithSymbol handles unknown currency code`() { + val value = BigDecimal("100.00") + + val formatted = value.formatCurrencyWithSymbol("XYZ") + + assertEquals("XYZ100.00", formatted) + } + + @Test + fun `formatCurrencyWithSymbol formats large amounts with grouping`() { + val value = BigDecimal("1234567.89") + + val formatted = value.formatCurrencyWithSymbol("USD") + + assertEquals("$1,234,567.89", formatted) + } + + @Test + fun `ConvertedAmount formattedWithSymbol returns correct format for prefix currency`() { + val converted = ConvertedAmount( + value = BigDecimal("10.50"), + formatted = "10.50", + symbol = "$", + currency = "USD", + flag = "🇺🇸", + sats = 1000L, + ) + + assertEquals("$10.50", converted.formattedWithSymbol()) + } + + @Test + fun `ConvertedAmount formattedWithSymbol returns correct format for suffix currency`() { + val converted = ConvertedAmount( + value = BigDecimal("0.35"), + formatted = "0.35", + symbol = "zł", + currency = "PLN", + flag = "🇵🇱", + sats = 100L, + ) + + assertEquals("0.35zł", converted.formattedWithSymbol()) + } + + @Test + fun `ConvertedAmount formattedWithSymbol returns correct format for prefix currency with space`() { + val converted = ConvertedAmount( + value = BigDecimal("10.50"), + formatted = "10.50", + symbol = "$", + currency = "USD", + flag = "🇺🇸", + sats = 1000L, + ) + + assertEquals("$ 10.50", converted.formattedWithSymbol(withSpace = true)) + } + + @Test + fun `ConvertedAmount formattedWithSymbol returns correct format for suffix currency with space`() { + val converted = ConvertedAmount( + value = BigDecimal("0.35"), + formatted = "0.35", + symbol = "zł", + currency = "PLN", + flag = "🇵🇱", + sats = 100L, + ) + + assertEquals("0.35 zł", converted.formattedWithSymbol(withSpace = true)) + } }