Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -318,10 +318,14 @@ class PaymentRequestActivity : AppCompatActivity() {
val entryUnit: String
val enteredAmount: Long

// Get current preferred currency to help resolve ambiguous symbols (like "kr" for SEK/NOK)
val currentCurrencyCode = CurrencyManager.getInstance(this).getCurrentCurrency()
val currentCurrency = Amount.Currency.fromCode(currentCurrencyCode)

if (tipAmountSats > 0 && baseAmountSats > 0) {
// Tip is present - use base amounts for accounting
// Parse base formatted amount to get the original entry unit
val parsedBase = Amount.parse(baseFormattedAmount)
val parsedBase = Amount.parse(baseFormattedAmount, currentCurrency)
if (parsedBase != null) {
entryUnit = if (parsedBase.currency == Currency.BTC) "sat" else parsedBase.currency.name
enteredAmount = parsedBase.value
Expand All @@ -333,7 +337,7 @@ class PaymentRequestActivity : AppCompatActivity() {
Log.d(TAG, "Creating pending payment with tip: base=$enteredAmount $entryUnit, tip=$tipAmountSats sats, total=$paymentAmount sats")
} else {
// No tip - parse the formatted amount string
val parsedAmount = Amount.parse(formattedAmountString)
val parsedAmount = Amount.parse(formattedAmountString, currentCurrency)
if (parsedAmount != null) {
entryUnit = if (parsedAmount.currency == Currency.BTC) "sat" else parsedAmount.currency.name
enteredAmount = parsedAmount.value
Expand Down
40 changes: 34 additions & 6 deletions app/src/main/java/com/electricdreams/numo/core/model/Amount.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ data class Amount(
USD("$"),
EUR("€"),
GBP("£"),
JPY("¥");
JPY("¥"),
DKK("kr."),
SEK("kr"),
NOK("kr");

/**
* Get the appropriate locale for formatting this currency.
Expand All @@ -37,6 +40,9 @@ data class Amount(
GBP -> Locale.UK // Period decimal: £4.20
JPY -> Locale.JAPAN // No decimals: ¥420
BTC -> Locale.US // Comma thousand separator: ₿1,000
DKK -> Locale("da", "DK") // Comma decimal: DKK 100,00
SEK -> Locale("sv", "SE") // Comma decimal: SEK 100,00
NOK -> Locale("nb", "NO") // Comma decimal: NOK 100,00
}

companion object {
Expand Down Expand Up @@ -115,17 +121,39 @@ data class Amount(
* Handles formats like "$0.25", "€1,50", "₿24", "¥100", etc.
* Accepts both period and comma as decimal separators for input flexibility.
* Returns null if parsing fails.
*
* @param formatted The formatted string to parse (e.g. "$10.50")
* @param defaultCurrency Optional default currency to use if the symbol is ambiguous (e.g. "kr" could be SEK or NOK).
*/
@JvmStatic
fun parse(formatted: String): Amount? {
@JvmOverloads
fun parse(formatted: String, defaultCurrency: Currency? = null): Amount? {
if (formatted.isEmpty()) return null

// Find the currency by the first character (symbol)
val symbol = formatted.take(1)
val currency = Currency.fromSymbol(symbol) ?: return null
// Find matching currencies by matching the start of the string
// We sort by symbol length descending to match longest symbols first (e.g. "kr." vs "kr")
val matchingCurrencies = Currency.entries
.filter { formatted.startsWith(it.symbol) }
.sortedByDescending { it.symbol.length }

if (matchingCurrencies.isEmpty()) return null

// If we have multiple matches (e.g. SEK and NOK both use "kr"), try to use the default currency
// Otherwise, pick the first one (which is deterministic but might be wrong if ambiguous)
// Note: Since we sorted by length, "kr." (DKK) will come before "kr" (SEK/NOK), which is correct behavior.
// The ambiguity is mainly between SEK and NOK.
val currency = if (matchingCurrencies.size > 1 && defaultCurrency != null) {
if (matchingCurrencies.contains(defaultCurrency)) {
defaultCurrency
} else {
matchingCurrencies.first()
}
} else {
matchingCurrencies.first()
}

// Extract the numeric part (remove symbol)
var numericPart = formatted.drop(1).trim()
var numericPart = formatted.drop(currency.symbol.length).trim()

// Normalize the input: handle both comma and period as decimal separators
// First, determine if comma is used as thousand separator or decimal separator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class CurrencyManager private constructor(context: Context) {
const val CURRENCY_EUR = "EUR"
const val CURRENCY_GBP = "GBP"
const val CURRENCY_JPY = "JPY"
const val CURRENCY_DKK = "DKK"
const val CURRENCY_SEK = "SEK"
const val CURRENCY_NOK = "NOK"

// Default currency is USD
private const val DEFAULT_CURRENCY = CURRENCY_USD
Expand Down Expand Up @@ -68,6 +71,9 @@ class CurrencyManager private constructor(context: Context) {
CURRENCY_GBP -> "£"
CURRENCY_JPY -> "¥"
CURRENCY_USD -> "$"
CURRENCY_DKK -> "kr."
CURRENCY_SEK -> "kr"
CURRENCY_NOK -> "kr"
else -> "$"
}

Expand All @@ -93,7 +99,8 @@ class CurrencyManager private constructor(context: Context) {
/** Check if a currency code is valid and supported. */
fun isValidCurrency(currencyCode: String?): Boolean {
return when (currencyCode) {
CURRENCY_USD, CURRENCY_EUR, CURRENCY_GBP, CURRENCY_JPY -> true
CURRENCY_USD, CURRENCY_EUR, CURRENCY_GBP, CURRENCY_JPY,
CURRENCY_DKK, CURRENCY_SEK, CURRENCY_NOK -> true
else -> false
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,18 @@ class ReceiptPrinter(private val context: Context) {

// Bitcoin price at time of transaction
data.bitcoinPrice?.let { price ->
val formattedPrice = String.format(Locale.US, "$%,.2f", price)
sb.appendLine(leftRight("BTC/USD Rate:", formattedPrice))
val priceCurrencyCode = data.basket?.currency ?: data.enteredCurrency
val priceCurrency = if (priceCurrencyCode.equals("BTC", ignoreCase = true) || priceCurrencyCode.equals("sat", ignoreCase = true)) {
Amount.Currency.USD
} else {
Amount.Currency.fromCode(priceCurrencyCode)
}

// Format price using Amount class
val priceMinorUnits = kotlin.math.round(price * 100).toLong()
val formattedPrice = Amount(priceMinorUnits, priceCurrency).toString()

sb.appendLine(leftRight("BTC/${priceCurrency.name} Rate:", formattedPrice))
}

sb.appendLine()
Expand Down Expand Up @@ -590,10 +600,22 @@ class ReceiptPrinter(private val context: Context) {

$tipSectionHtml

${data.bitcoinPrice?.let { """
${data.bitcoinPrice?.let { price ->
val priceCurrencyCode = data.basket?.currency ?: data.enteredCurrency
val priceCurrency = if (priceCurrencyCode.equals("BTC", ignoreCase = true) || priceCurrencyCode.equals("sat", ignoreCase = true)) {
Amount.Currency.USD
} else {
Amount.Currency.fromCode(priceCurrencyCode)
}

// Format price using Amount class
val priceMinorUnits = kotlin.math.round(price * 100).toLong()
val formattedPrice = Amount(priceMinorUnits, priceCurrency).toString()

"""
<div class="row small" style="margin-top: 4px;">
<span>BTC/USD Rate:</span>
<span>${String.format(Locale.US, "$%,.2f", it)}</span>
<span>BTC/${priceCurrency.name} Rate:</span>
<span>$formattedPrice</span>
</div>
""" } ?: ""}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ class BitcoinPriceWorker private constructor(context: Context) {
CurrencyManager.CURRENCY_EUR,
CurrencyManager.CURRENCY_GBP,
CurrencyManager.CURRENCY_JPY,
CurrencyManager.CURRENCY_DKK,
CurrencyManager.CURRENCY_SEK,
CurrencyManager.CURRENCY_NOK,
)

for (currency in supportedCurrencies) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,40 +133,22 @@ class TransactionDetailActivity : AppCompatActivity() {
val amountSubtitleText: TextView = findViewById(R.id.detail_amount_subtitle)
val amountValueText: TextView = findViewById(R.id.detail_amount_value)

// Parse basket to determine display mode
val basket = CheckoutBasket.fromJson(checkoutBasketJson)
val showSatsAsPrimary = basket?.let {
it.hasMixedPriceTypes() || it.getFiatItems().isEmpty()
} ?: (entry.getEntryUnit() == "sat")

// Use BASE amount (excluding tip) for display - this is what was sold
val baseAmountSats = entry.getBaseAmountSats()
val baseSatAmount = Amount(baseAmountSats, Amount.Currency.BTC)

if (showSatsAsPrimary) {
// Primary: Sats (base amount)
amountText.text = baseSatAmount.toString()
amountValueText.text = baseSatAmount.toString()

// Secondary: Fiat equivalent
if (entry.enteredAmount > 0 && entry.getEntryUnit() != "sat") {
val entryCurrency = Amount.Currency.fromCode(entry.getEntryUnit())
val fiatAmount = Amount(entry.enteredAmount, entryCurrency)
amountSubtitleText.text = "≈ $fiatAmount"
amountSubtitleText.visibility = View.VISIBLE
} else {
amountSubtitleText.visibility = View.GONE
}
} else {
// Primary: Fiat (entered amount - which is the base amount)

// Amount is ALWAYS the settlement one in sats (per user request)
amountText.text = baseSatAmount.toString()
amountValueText.text = baseSatAmount.toString()

// Secondary: Fiat equivalent (if applicable)
if (entry.enteredAmount > 0 && entry.getEntryUnit() != "sat") {
val entryCurrency = Amount.Currency.fromCode(entry.getEntryUnit())
val fiatAmount = Amount(entry.enteredAmount, entryCurrency)
amountText.text = fiatAmount.toString()
amountValueText.text = fiatAmount.toString()

// Secondary: Sats paid (base amount, not total)
amountSubtitleText.text = baseSatAmount.toString()
amountSubtitleText.text = "≈ $fiatAmount"
amountSubtitleText.visibility = View.VISIBLE
} else {
amountSubtitleText.visibility = View.GONE
}

// Date
Expand Down Expand Up @@ -227,7 +209,7 @@ class TransactionDetailActivity : AppCompatActivity() {
if (entry.getEntryUnit() != "sat") {
val entryCurrency = Amount.Currency.fromCode(entry.getEntryUnit())
val enteredAmount = Amount(entry.enteredAmount, entryCurrency)
enteredAmountText.text = enteredAmount.toString()
enteredAmountText.text = enteredAmount.toStringWithoutSymbol()
enteredAmountRow.visibility = View.VISIBLE
enteredAmountDivider.visibility = View.VISIBLE
} else {
Expand All @@ -242,7 +224,26 @@ class TransactionDetailActivity : AppCompatActivity() {

val btcPrice = entry.bitcoinPrice
if (btcPrice != null && btcPrice > 0) {
val formattedPrice = String.format(Locale.US, "$%,.2f", btcPrice)
// Determine the correct currency for the Bitcoin price.
// 1. If entry unit is a fiat currency, use that.
// 2. If available, check the checkout basket currency.
// 3. Fallback to USD (default behavior).
val currencyCode = if (entry.getEntryUnit() != "sat" && entry.getEntryUnit() != "BTC") {
entry.getEntryUnit()
} else {
val basket = CheckoutBasket.fromJson(checkoutBasketJson)
basket?.currency ?: "USD"
}

val currency = Amount.Currency.fromCode(currencyCode)
// If we somehow still resolved to BTC (e.g. basket had "SAT"), force USD for price display
val priceCurrency = if (currency == Amount.Currency.BTC) Amount.Currency.USD else currency

// Format price using Amount class to respect locale and currency symbol
// Amount expects minor units (cents), so multiply by 100
val priceMinorUnits = kotlin.math.round(btcPrice * 100).toLong()
val formattedPrice = Amount(priceMinorUnits, priceCurrency).toString()

bitcoinPriceText.text = formattedPrice
bitcoinPriceRow.visibility = View.VISIBLE
bitcoinPriceDivider.visibility = View.VISIBLE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class CurrencySettingsActivity : AppCompatActivity() {
private lateinit var radioEur: RadioButton
private lateinit var radioGbp: RadioButton
private lateinit var radioJpy: RadioButton
private lateinit var radioDkk: RadioButton
private lateinit var radioSek: RadioButton
private lateinit var radioNok: RadioButton
private lateinit var currencyManager: CurrencyManager

override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -30,6 +33,9 @@ class CurrencySettingsActivity : AppCompatActivity() {
radioEur = findViewById(R.id.radio_eur)
radioGbp = findViewById(R.id.radio_gbp)
radioJpy = findViewById(R.id.radio_jpy)
radioDkk = findViewById(R.id.radio_dkk)
radioSek = findViewById(R.id.radio_sek)
radioNok = findViewById(R.id.radio_nok)

setSelectedCurrency(currencyManager.getCurrentCurrency())

Expand All @@ -45,6 +51,9 @@ class CurrencySettingsActivity : AppCompatActivity() {
CurrencyManager.CURRENCY_GBP -> radioGbp.isChecked = true
CurrencyManager.CURRENCY_JPY -> radioJpy.isChecked = true
CurrencyManager.CURRENCY_USD -> radioUsd.isChecked = true
CurrencyManager.CURRENCY_DKK -> radioDkk.isChecked = true
CurrencyManager.CURRENCY_SEK -> radioSek.isChecked = true
CurrencyManager.CURRENCY_NOK -> radioNok.isChecked = true
else -> radioUsd.isChecked = true
}
}
Expand All @@ -55,6 +64,9 @@ class CurrencySettingsActivity : AppCompatActivity() {
R.id.radio_eur -> CurrencyManager.CURRENCY_EUR
R.id.radio_gbp -> CurrencyManager.CURRENCY_GBP
R.id.radio_jpy -> CurrencyManager.CURRENCY_JPY
R.id.radio_dkk -> CurrencyManager.CURRENCY_DKK
R.id.radio_sek -> CurrencyManager.CURRENCY_SEK
R.id.radio_nok -> CurrencyManager.CURRENCY_NOK
else -> CurrencyManager.CURRENCY_USD
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ class TipSelectionActivity : AppCompatActivity() {
if (entryCurrency != Currency.BTC) {
enteredAmountFiat = parsedAmount.value
}
} else {
// Fallback parsing with current system currency if implicit parsing fails
// (e.g. for ambiguous symbols like "kr")
val currencyManager = com.electricdreams.numo.core.util.CurrencyManager.getInstance(this)
val currentCurrencyCode = currencyManager.getCurrentCurrency()
val currentCurrency = Amount.Currency.fromCode(currentCurrencyCode)
val parsedWithContext = Amount.parse(formattedAmount, currentCurrency)

if (parsedWithContext != null) {
entryCurrency = parsedWithContext.currency
if (entryCurrency != Currency.BTC) {
enteredAmountFiat = parsedWithContext.value
}
}
}

// Set default for custom input based on entry currency
Expand Down Expand Up @@ -838,8 +852,7 @@ class TipSelectionActivity : AppCompatActivity() {
}
val totalFiat = (enteredAmountFiat / 100.0) + tipFiat
// Format as currency
val symbol = entryCurrency.symbol
"${symbol}${String.format("%.2f", totalFiat)}"
Amount(kotlin.math.round(totalFiat * 100).toLong(), entryCurrency).toString()
} else {
formattedAmount
}
Expand Down
39 changes: 39 additions & 0 deletions app/src/main/res/layout/activity_currency_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,45 @@
android:paddingHorizontal="24dp"
android:text="@string/currency_settings_option_jpy"
android:textAppearance="@style/Text.BodyBold" />

<RadioButton
android:id="@+id/radio_dkk"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?attr/selectableItemBackground"
android:button="@null"
android:drawableEnd="?android:attr/listChoiceIndicatorSingle"
android:drawableTint="@color/color_primary_green"
android:gravity="center_vertical"
android:paddingHorizontal="24dp"
android:text="@string/currency_settings_option_dkk"
android:textAppearance="@style/Text.BodyBold" />

<RadioButton
android:id="@+id/radio_sek"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?attr/selectableItemBackground"
android:button="@null"
android:drawableEnd="?android:attr/listChoiceIndicatorSingle"
android:drawableTint="@color/color_primary_green"
android:gravity="center_vertical"
android:paddingHorizontal="24dp"
android:text="@string/currency_settings_option_sek"
android:textAppearance="@style/Text.BodyBold" />

<RadioButton
android:id="@+id/radio_nok"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?attr/selectableItemBackground"
android:button="@null"
android:drawableEnd="?android:attr/listChoiceIndicatorSingle"
android:drawableTint="@color/color_primary_green"
android:gravity="center_vertical"
android:paddingHorizontal="24dp"
android:text="@string/currency_settings_option_nok"
android:textAppearance="@style/Text.BodyBold" />
</RadioGroup>

</LinearLayout>
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values-es/strings_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
<string name="currency_settings_option_eur">Euro (EUR)</string>
<string name="currency_settings_option_gbp">Libra esterlina (GBP)</string>
<string name="currency_settings_option_jpy">Yen japonés (JPY)</string>
<string name="currency_settings_option_dkk">Corona danesa (DKK)</string>
<string name="currency_settings_option_sek">Corona sueca (SEK)</string>
<string name="currency_settings_option_nok">Corona noruega (NOK)</string>

<!-- Settings items: mints -->
<string name="settings_item_mints_title">Mints</string>
Expand Down
Loading