fiat ux
This commit is contained in:
@@ -1,15 +1,20 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.send
|
package com.bitcointxoko.gudariwallet.ui.send
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PersonAdd
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.SuggestionChip
|
import androidx.compose.material3.SuggestionChip
|
||||||
@@ -20,13 +25,18 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
import com.bitcointxoko.gudariwallet.ui.SendState
|
import com.bitcointxoko.gudariwallet.ui.SendState
|
||||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||||
@@ -37,6 +47,8 @@ import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
|
|||||||
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||||
|
|
||||||
|
private enum class AmountUnit { SATS, FIAT }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LnurlPayForm(
|
internal fun LnurlPayForm(
|
||||||
state : SendState.LnurlReady,
|
state : SendState.LnurlReady,
|
||||||
@@ -47,13 +59,34 @@ internal fun LnurlPayForm(
|
|||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
var amount by remember { mutableStateOf(state.minSats.toString()) }
|
var amountText by remember { mutableStateOf(state.minSats.toString()) }
|
||||||
var comment by remember { mutableStateOf("") }
|
var comment by remember { mutableStateOf("") }
|
||||||
val isFixed = state.minSats == state.maxSats
|
val isFixed = state.minSats == state.maxSats
|
||||||
|
|
||||||
val sats: Long? = amount.toLongOrNull()
|
val showFiatToggle = fiatRate != null && fiatCurrency != null
|
||||||
|
var activeUnit by remember { mutableStateOf(AmountUnit.SATS) }
|
||||||
|
|
||||||
|
// ── Always compute sats from whatever the user typed ─────────────────
|
||||||
|
val parsedInput = amountText.trim().toDoubleOrNull()
|
||||||
|
val sats: Long? = when {
|
||||||
|
parsedInput == null -> null
|
||||||
|
activeUnit == AmountUnit.SATS -> parsedInput.toLong()
|
||||||
|
else /* FIAT */ -> (parsedInput * (fiatRate ?: 1.0)).toLong()
|
||||||
|
}
|
||||||
val isAmountValid = sats != null && sats in state.minSats..state.maxSats
|
val isAmountValid = sats != null && sats in state.minSats..state.maxSats
|
||||||
val fiatLabel: String? = fiatLabel(sats, fiatRate, fiatCurrency)
|
|
||||||
|
// ── Button labels — always sats + fiat, regardless of active unit ────
|
||||||
|
val satsLabelForButton: String = sats?.toString() ?: ""
|
||||||
|
val fiatLabelForButton: String? = if (sats != null) fiatLabel(sats, fiatRate, fiatCurrency) else null
|
||||||
|
|
||||||
|
// ── Conversion label (the opposite unit) for supporting text ─────────
|
||||||
|
val conversionLabel: String? = remember(amountText, activeUnit, fiatRate, fiatCurrency) {
|
||||||
|
if (!showFiatToggle || parsedInput == null) return@remember null
|
||||||
|
when (activeUnit) {
|
||||||
|
AmountUnit.SATS -> fiatLabel(parsedInput.toLong(), fiatRate, fiatCurrency)
|
||||||
|
AmountUnit.FIAT -> "≈ ${(parsedInput * (fiatRate ?: 1.0)).toLong()} sats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contact lookup — reactive, sourced from WalletViewModel ──────────
|
// ── Contact lookup — reactive, sourced from WalletViewModel ──────────
|
||||||
val existingContact by vm.contactForCurrentLnurl.collectAsStateWithLifecycle()
|
val existingContact by vm.contactForCurrentLnurl.collectAsStateWithLifecycle()
|
||||||
@@ -67,10 +100,14 @@ internal fun LnurlPayForm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The supporting-text height the picker must not center against ─────
|
||||||
|
// OutlinedTextFieldDefaults counts: input area ~56 dp, supporting
|
||||||
|
// text ~16 dp with 4 dp internal padding ≈ 20 dp extra below.
|
||||||
|
val supportingTextHeight = 20.dp
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
// ── Recipient header ──────────────────────────────────────────────
|
// ── Recipient header ──────────────────────────────────────────────
|
||||||
if (existingContact != null) {
|
if (existingContact != null) {
|
||||||
// Known contact — show their name instead of the raw address
|
|
||||||
val contactName = existingContact!!.localAlias
|
val contactName = existingContact!!.localAlias
|
||||||
?: existingContact!!.displayName
|
?: existingContact!!.displayName
|
||||||
?: existingContact!!.name
|
?: existingContact!!.name
|
||||||
@@ -80,7 +117,6 @@ internal fun LnurlPayForm(
|
|||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Unknown — show domain/address as before
|
|
||||||
Text(
|
Text(
|
||||||
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)),
|
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)),
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
@@ -108,45 +144,106 @@ internal fun LnurlPayForm(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
// ── Headroom above amount row — the picker has a 32 dp ghost item
|
||||||
|
// above the selected item; this offsets so the visual centre of
|
||||||
|
// the picker lines up with the title text above rather than the
|
||||||
|
// Row's top edge.
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// ── Amount field — unchanged ──────────────────────────────────────
|
// ── Amount field with currency switcher ───────────────────────────
|
||||||
OutlinedTextField(
|
Row(
|
||||||
value = amount,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onValueChange = { if (it.all(Char::isDigit)) amount = it },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
label = { Text(strings.receive.amountInSats) },
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
isError = amount.isNotBlank() && !isAmountValid,
|
) {
|
||||||
supportingText = {
|
|
||||||
when {
|
|
||||||
amount.isNotBlank() && !isAmountValid -> {
|
|
||||||
Text(
|
|
||||||
text = strings.lnurlAmountError(state.minSats, state.maxSats),
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
fiatRate != null && fiatCurrency != null -> {
|
|
||||||
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
|
|
||||||
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
|
|
||||||
if (isFixed) Text(strings.lnurlFiatRange(minFiat, maxFiat))
|
|
||||||
else Text(strings.lnurlSatsAndFiatRange(state.minSats, state.maxSats, minFiat, maxFiat))
|
|
||||||
}
|
|
||||||
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats))
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isFixed,
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Comment field — unchanged ─────────────────────────────────────
|
|
||||||
if (state.commentAllowed > 0) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = comment,
|
value = amountText,
|
||||||
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
|
onValueChange = {
|
||||||
label = { Text(strings.commentOptional(state.commentAllowed)) },
|
val allowed = if (activeUnit == AmountUnit.SATS)
|
||||||
|
it.all(Char::isDigit)
|
||||||
|
else
|
||||||
|
it.all { c -> c.isDigit() || c == '.' } &&
|
||||||
|
it.count { c -> c == '.' } <= 1
|
||||||
|
if (allowed) amountText = it
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
if (activeUnit == AmountUnit.SATS || !showFiatToggle) "0" else "0.00",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isError = amountText.isNotBlank() && !isAmountValid,
|
||||||
|
supportingText = {
|
||||||
|
when {
|
||||||
|
amountText.isNotBlank() && !isAmountValid -> {
|
||||||
|
Text(
|
||||||
|
text = strings.lnurlAmountError(state.minSats, state.maxSats),
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
conversionLabel != null -> {
|
||||||
|
Text(
|
||||||
|
text = conversionLabel,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fiatRate != null && fiatCurrency != null -> {
|
||||||
|
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
|
||||||
|
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
|
||||||
|
if (isFixed) Text(strings.lnurlFiatRange(minFiat, maxFiat))
|
||||||
|
else Text(strings.lnurlSatsAndFiatRange(state.minSats, state.maxSats, minFiat, maxFiat))
|
||||||
|
}
|
||||||
|
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats))
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isFixed,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = LocalTextStyle.current.copy(
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = if (activeUnit == AmountUnit.SATS) KeyboardType.Number else KeyboardType.Decimal,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Offset the picker upward by half the supporting-text height
|
||||||
|
// so its selected item aligns with the text field's input
|
||||||
|
// area (the visible text) rather than the geometric centre of
|
||||||
|
// the entire OutlinedTextField including the sub-label below.
|
||||||
|
UnitWheelPicker(
|
||||||
|
units = if (showFiatToggle) listOf(strings.sats, fiatCurrency) else listOf(strings.sats),
|
||||||
|
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
|
||||||
|
onIndexSelected = { newIndex ->
|
||||||
|
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
|
||||||
|
if (newUnit != activeUnit) {
|
||||||
|
activeUnit = newUnit
|
||||||
|
amountText = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.width(64.dp)
|
||||||
|
.offset(y = -supportingTextHeight / 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gap after amount row — accounts for the picker extending below
|
||||||
|
// (picker is 96 dp, text field input area ~56 dp → ~40 dp
|
||||||
|
// overhang below, minus the offset above). Keep this compact so
|
||||||
|
// all inputs + title + button feel evenly spaced.
|
||||||
|
Spacer(Modifier.height(21.dp))
|
||||||
|
|
||||||
|
// ── Comment field ─────────────────────────────────────────────────
|
||||||
|
if (state.commentAllowed > 0) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = comment,
|
||||||
|
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
|
||||||
|
label = { Text(strings.commentOptional(state.commentAllowed)) },
|
||||||
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) },
|
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
@@ -154,17 +251,17 @@ internal fun LnurlPayForm(
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// ── Pay button — unchanged ────────────────────────────────────────
|
// ── Pay button ────────────────────────────────────────────────────
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (sats != null && isAmountValid) {
|
if (sats != null && isAmountValid) {
|
||||||
vm.fetchLnurlInvoice(
|
vm.fetchLnurlInvoice(
|
||||||
rawRes = state.rawRes,
|
rawRes = state.rawRes,
|
||||||
lnurl = state.lnurl,
|
lnurl = state.lnurl,
|
||||||
domain = state.domain,
|
domain = state.domain,
|
||||||
amountMsat = sats * WalletConstants.MSAT_PER_SAT,
|
amountMsat = sats * WalletConstants.MSAT_PER_SAT,
|
||||||
comment = comment.takeIf { it.isNotBlank() },
|
comment = comment.takeIf { it.isNotBlank() },
|
||||||
strings = sendStrings,
|
strings = sendStrings,
|
||||||
contactId = existingContact?.id
|
contactId = existingContact?.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -173,8 +270,8 @@ internal fun LnurlPayForm(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel)
|
if (fiatLabelForButton != null) strings.lnurlPayButtonWithFiat(satsLabelForButton, fiatLabelForButton)
|
||||||
else strings.lnurlPayButton(amount)
|
else strings.lnurlPayButton(satsLabelForButton)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,4 +289,3 @@ internal fun LnurlPayForm(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +1,81 @@
|
|||||||
package com.bitcointxoko.gudariwallet.util
|
package com.bitcointxoko.gudariwallet.util
|
||||||
|
|
||||||
/**
|
import java.text.DecimalFormatSymbols
|
||||||
* Converts a satoshi amount to its fiat equivalent.
|
import java.util.Locale
|
||||||
*
|
|
||||||
* @param sats Amount in satoshis.
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
* @param satsPerFiat How many sats equal 1 fiat unit (e.g. if BTC = $100,000 then
|
|
||||||
* satsPerFiat = 100,000,000 / 100,000 = 1,000 sats per dollar).
|
|
||||||
* @return Fiat amount as a Double.
|
|
||||||
*/
|
|
||||||
fun satsToFiat(sats: Long, satsPerFiat: Double): Double =
|
fun satsToFiat(sats: Long, satsPerFiat: Double): Double =
|
||||||
sats / satsPerFiat
|
sats / satsPerFiat
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a fiat amount for display with a currency symbol prefix.
|
|
||||||
*
|
|
||||||
* Tiers (bitcoin.design rules):
|
|
||||||
* - amount >= 1.00 → 2 dp, e.g. "$43.25"
|
|
||||||
* - 0.01 <= amount < 1.00 → 4 dp, e.g. "$0.0043"
|
|
||||||
* - amount < 0.01 (sub-cent)→ dynamic precision with "~" prefix,
|
|
||||||
* e.g. "~$0.0039" (2 significant digits after leading zeros)
|
|
||||||
* - amount == 0.0 → "$0.00"
|
|
||||||
*
|
|
||||||
* @param amount Fiat amount to format (always pass a positive value; sign is handled by caller).
|
|
||||||
* @param currency ISO 4217 currency code (e.g. "USD", "EUR").
|
|
||||||
* @return Formatted string with symbol, e.g. "$43.25" or "~$0.0039".
|
|
||||||
*/
|
|
||||||
fun formatFiat(amount: Double, currency: String): String {
|
fun formatFiat(amount: Double, currency: String): String {
|
||||||
val symbol = currencySymbol(currency)
|
val symbol = currencySymbol(currency)
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
amount == 0.0 -> "${symbol}0.00"
|
amount == 0.0 -> "${symbol}${localiseDecimal("0.00")}"
|
||||||
amount >= 1.0 -> "$symbol${"%.2f".format(amount)}"
|
amount >= 1.0 -> {
|
||||||
amount >= 0.01 -> "$symbol${"%.4f".format(amount)}"
|
val formatted = "%.2f".format(Locale.US, amount)
|
||||||
else -> formatSubCent(amount, symbol)
|
val isExact = formatted.toDouble() == amount
|
||||||
|
val prefix = if (isExact) "" else "~"
|
||||||
|
"$prefix$symbol${localiseDecimal(formatted)}"
|
||||||
|
}
|
||||||
|
amount >= 0.01 -> formatWithSigDigits(amount, symbol, minDecimals = 2)
|
||||||
|
else -> formatWithSigDigits(amount, symbol, minDecimals = 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts sats → fiat and formats in one call, applying bitcoin.design display rules.
|
|
||||||
*
|
|
||||||
* Use this everywhere you display a sat amount alongside a fiat equivalent.
|
|
||||||
*
|
|
||||||
* @param sats Absolute satoshi count (always positive; caller adds +/− sign).
|
|
||||||
* @param satsPerFiat Exchange rate: sats per 1 fiat unit.
|
|
||||||
* @param currency ISO 4217 currency code.
|
|
||||||
* @return Formatted fiat string, e.g. "$12.50", "~$0.0039", or "$0.00".
|
|
||||||
*/
|
|
||||||
fun formatFiatForSats(sats: Long, satsPerFiat: Double, currency: String): String =
|
fun formatFiatForSats(sats: Long, satsPerFiat: Double, currency: String): String =
|
||||||
formatFiat(satsToFiat(sats, satsPerFiat), currency)
|
formatFiat(satsToFiat(sats, satsPerFiat), currency)
|
||||||
|
|
||||||
|
fun fiatLabel(
|
||||||
|
sats: Long?,
|
||||||
|
fiatRate: Double?,
|
||||||
|
fiatCurrency: String?,
|
||||||
|
minSats: Long = 1L
|
||||||
|
): String? =
|
||||||
|
if (sats != null && sats >= minSats && fiatRate != null && fiatCurrency != null)
|
||||||
|
formatFiat(satsToFiat(sats, fiatRate), fiatCurrency)
|
||||||
|
else null
|
||||||
|
|
||||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
private fun formatWithSigDigits(amount: Double, symbol: String, minDecimals: Int): String {
|
||||||
* Handles the sub-cent case (0 < amount < 0.01).
|
// Use Locale.US so "." is always the decimal char — safe for arithmetic & comparison
|
||||||
*
|
val raw = "%.15f".format(Locale.US, amount)
|
||||||
* Finds the first two significant (non-zero) digits after the decimal point
|
val dotIdx = raw.indexOf('.')
|
||||||
* and rounds to that precision, prefixing with "~" to signal approximation.
|
if (dotIdx == -1) return "$symbol${localiseDecimal(raw)}"
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* 0.003887 → "~$0.0039"
|
|
||||||
* 0.000038 → "~$0.000038"
|
|
||||||
* 0.0000431 → "~$0.000043"
|
|
||||||
*/
|
|
||||||
private fun formatSubCent(amount: Double, symbol: String): String {
|
|
||||||
// Render with enough decimal places to capture at least 2 significant digits
|
|
||||||
val raw = "%.10f".format(amount) // e.g. "0.0000431000"
|
|
||||||
val dotIdx = raw.indexOf('.')
|
|
||||||
if (dotIdx == -1) return "~$symbol$raw"
|
|
||||||
|
|
||||||
val decimals = raw.substring(dotIdx + 1) // e.g. "0000431000"
|
val decimals = raw.substring(dotIdx + 1)
|
||||||
val firstSigIdx = decimals.indexOfFirst { it != '0' }
|
val firstSigIdx = decimals.indexOfFirst { it != '0' }
|
||||||
if (firstSigIdx == -1) return "${symbol}0.00" // effectively zero
|
|
||||||
|
|
||||||
// Keep all leading zeros + 2 significant digits
|
if (firstSigIdx == -1) return "${symbol}${localiseDecimal("0.00")}"
|
||||||
val precision = firstSigIdx + 2
|
|
||||||
val rounded = "%.${precision}f".format(amount)
|
|
||||||
|
|
||||||
// Only add "~" if rounding actually changed the value
|
val sigPrecision = firstSigIdx + 2
|
||||||
val isExact = "%.${precision}f".format(amount).toDoubleOrNull() == amount
|
val precision = maxOf(sigPrecision, minDecimals)
|
||||||
return if (isExact) "$symbol$rounded" else "~$symbol$rounded"
|
|
||||||
|
val rounded = "%.${precision}f".format(Locale.US, amount)
|
||||||
|
val isExact = rounded.toDouble() == amount // safe: dot separator guaranteed
|
||||||
|
|
||||||
|
val prefix = if (isExact) "" else "~"
|
||||||
|
return "$prefix$symbol${localiseDecimal(rounded)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the display symbol for a given ISO 4217 currency code.
|
* Converts a US-formatted decimal string (dot separator, no grouping)
|
||||||
* Falls back to the currency code + space for unlisted currencies.
|
* into the user's current locale format.
|
||||||
|
*
|
||||||
|
* "155.57" → "155,57" (DE locale)
|
||||||
|
* "0.0039" → "0,0039" (DE locale)
|
||||||
|
* "155.57" → "155.57" (US locale)
|
||||||
*/
|
*/
|
||||||
|
private fun localiseDecimal(usFormatted: String): String {
|
||||||
|
val symbols = DecimalFormatSymbols.getInstance() // uses device default locale
|
||||||
|
val localeDecimalSep = symbols.decimalSeparator
|
||||||
|
// Only substitute if the locale actually uses a different separator
|
||||||
|
return if (localeDecimalSep == '.') usFormatted
|
||||||
|
else usFormatted.replace('.', localeDecimalSep)
|
||||||
|
}
|
||||||
|
|
||||||
private fun currencySymbol(currency: String): String = when (currency) {
|
private fun currencySymbol(currency: String): String = when (currency) {
|
||||||
"USD" -> "$"
|
"USD" -> "$"
|
||||||
"EUR" -> "€"
|
"EUR" -> "€"
|
||||||
@@ -96,12 +87,3 @@ private fun currencySymbol(currency: String): String = when (currency) {
|
|||||||
"CHF" -> "Fr"
|
"CHF" -> "Fr"
|
||||||
else -> "$currency "
|
else -> "$currency "
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a formatted fiat string for [sats] at the given rate, or null if
|
|
||||||
* rate or currency is unavailable. Suppresses display for zero/null amounts.
|
|
||||||
*/
|
|
||||||
fun fiatLabel(sats: Long?, fiatRate: Double?, fiatCurrency: String?, minSats: Long = 1L): String? =
|
|
||||||
if (sats != null && sats >= minSats && fiatRate != null && fiatCurrency != null)
|
|
||||||
formatFiat(satsToFiat(sats, fiatRate), fiatCurrency)
|
|
||||||
else null
|
|
||||||
|
|||||||
Reference in New Issue
Block a user