This commit is contained in:
2026-06-14 13:30:11 +02:00
parent bcea6451e7
commit 985c80373d
2 changed files with 203 additions and 125 deletions
@@ -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,22 +144,51 @@ 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 ───────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField( OutlinedTextField(
value = amount, value = amountText,
onValueChange = { if (it.all(Char::isDigit)) amount = it }, onValueChange = {
label = { Text(strings.receive.amountInSats) }, val allowed = if (activeUnit == AmountUnit.SATS)
isError = amount.isNotBlank() && !isAmountValid, 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 = { supportingText = {
when { when {
amount.isNotBlank() && !isAmountValid -> { amountText.isNotBlank() && !isAmountValid -> {
Text( Text(
text = strings.lnurlAmountError(state.minSats, state.maxSats), text = strings.lnurlAmountError(state.minSats, state.maxSats),
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
} }
conversionLabel != null -> {
Text(
text = conversionLabel,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
fiatRate != null && fiatCurrency != null -> { fiatRate != null && fiatCurrency != null -> {
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency) val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency) val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
@@ -136,13 +201,45 @@ internal fun LnurlPayForm(
}, },
enabled = !isFixed, enabled = !isFixed,
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), textStyle = LocalTextStyle.current.copy(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) 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)
) )
// ── Comment field — unchanged ───────────────────────────────────── // ── 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) { if (state.commentAllowed > 0) {
Spacer(Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = comment, value = comment,
onValueChange = { if (it.length <= state.commentAllowed) comment = it }, onValueChange = { if (it.length <= state.commentAllowed) comment = it },
@@ -154,7 +251,7 @@ 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) {
@@ -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
* and rounds to that precision, prefixing with "~" to signal approximation.
*
* 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('.') val dotIdx = raw.indexOf('.')
if (dotIdx == -1) return "~$symbol$raw" if (dotIdx == -1) return "$symbol${localiseDecimal(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