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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SuggestionChip
@@ -20,13 +25,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.util.fiatLabel
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.SendInputType
private enum class AmountUnit { SATS, FIAT }
@Composable
internal fun LnurlPayForm(
state : SendState.LnurlReady,
@@ -47,13 +59,34 @@ internal fun LnurlPayForm(
) {
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("") }
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 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 ──────────
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 {
// ── Recipient header ──────────────────────────────────────────────
if (existingContact != null) {
// Known contact — show their name instead of the raw address
val contactName = existingContact!!.localAlias
?: existingContact!!.displayName
?: existingContact!!.name
@@ -80,7 +117,6 @@ internal fun LnurlPayForm(
style = MaterialTheme.typography.titleMedium
)
} else {
// Unknown — show domain/address as before
Text(
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)),
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(
value = amount,
onValueChange = { if (it.all(Char::isDigit)) amount = it },
label = { Text(strings.receive.amountInSats) },
isError = amount.isNotBlank() && !isAmountValid,
value = amountText,
onValueChange = {
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 {
amount.isNotBlank() && !isAmountValid -> {
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)
@@ -136,13 +201,45 @@ internal fun LnurlPayForm(
},
enabled = !isFixed,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
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)
)
// ── 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) {
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = comment,
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
@@ -154,7 +251,7 @@ internal fun LnurlPayForm(
Spacer(Modifier.height(16.dp))
// ── Pay button — unchanged ────────────────────────────────────────
// ── Pay button ────────────────────────────────────────────────────
Button(
onClick = {
if (sats != null && isAmountValid) {
@@ -173,8 +270,8 @@ internal fun LnurlPayForm(
modifier = Modifier.fillMaxWidth()
) {
Text(
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel)
else strings.lnurlPayButton(amount)
if (fiatLabelForButton != null) strings.lnurlPayButtonWithFiat(satsLabelForButton, fiatLabelForButton)
else strings.lnurlPayButton(satsLabelForButton)
)
}
}
@@ -192,4 +289,3 @@ internal fun LnurlPayForm(
)
}
}
@@ -1,90 +1,81 @@
package com.bitcointxoko.gudariwallet.util
/**
* Converts a satoshi amount to its fiat equivalent.
*
* @param sats Amount in satoshis.
* @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.
*/
import java.text.DecimalFormatSymbols
import java.util.Locale
// ── Public API ────────────────────────────────────────────────────────────────
fun satsToFiat(sats: Long, satsPerFiat: Double): Double =
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 {
val symbol = currencySymbol(currency)
return when {
amount == 0.0 -> "${symbol}0.00"
amount >= 1.0 -> "$symbol${"%.2f".format(amount)}"
amount >= 0.01 -> "$symbol${"%.4f".format(amount)}"
else -> formatSubCent(amount, symbol)
amount == 0.0 -> "${symbol}${localiseDecimal("0.00")}"
amount >= 1.0 -> {
val formatted = "%.2f".format(Locale.US, amount)
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 =
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 ──────────────────────────────────────────────────────────
/**
* Handles the sub-cent case (0 < amount < 0.01).
*
* 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"
private fun formatWithSigDigits(amount: Double, symbol: String, minDecimals: Int): String {
// Use Locale.US so "." is always the decimal char — safe for arithmetic & comparison
val raw = "%.15f".format(Locale.US, amount)
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' }
if (firstSigIdx == -1) return "${symbol}0.00" // effectively zero
// Keep all leading zeros + 2 significant digits
val precision = firstSigIdx + 2
val rounded = "%.${precision}f".format(amount)
if (firstSigIdx == -1) return "${symbol}${localiseDecimal("0.00")}"
// Only add "~" if rounding actually changed the value
val isExact = "%.${precision}f".format(amount).toDoubleOrNull() == amount
return if (isExact) "$symbol$rounded" else "~$symbol$rounded"
val sigPrecision = firstSigIdx + 2
val precision = maxOf(sigPrecision, minDecimals)
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.
* Falls back to the currency code + space for unlisted currencies.
* Converts a US-formatted decimal string (dot separator, no grouping)
* 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) {
"USD" -> "$"
"EUR" -> ""
@@ -96,12 +87,3 @@ private fun currencySymbol(currency: String): String = when (currency) {
"CHF" -> "Fr"
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