From 985c80373db212fe2553f168644f9f9ae8e58d3c Mon Sep 17 00:00:00 2001 From: rasputin Date: Sun, 14 Jun 2026 13:30:11 +0200 Subject: [PATCH] fiat ux --- .../gudariwallet/ui/send/LnurlPayForm.kt | 202 +++++++++++++----- .../gudariwallet/util/CurrencyFormatter.kt | 126 +++++------ 2 files changed, 203 insertions(+), 125 deletions(-) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt index e8d3754..2c658ad 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt @@ -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 comment by remember { mutableStateOf("") } - val isFixed = state.minSats == state.maxSats + 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,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 ────────────────────────────────────── - OutlinedTextField( - value = amount, - onValueChange = { if (it.all(Char::isDigit)) amount = it }, - label = { Text(strings.receive.amountInSats) }, - 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)) + // ── Amount field with currency switcher ─────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { OutlinedTextField( - value = comment, - onValueChange = { if (it.length <= state.commentAllowed) comment = it }, - label = { Text(strings.commentOptional(state.commentAllowed)) }, + 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 { + 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)) }, modifier = Modifier.fillMaxWidth() ) @@ -154,17 +251,17 @@ internal fun LnurlPayForm( Spacer(Modifier.height(16.dp)) - // ── Pay button — unchanged ──────────────────────────────────────── + // ── Pay button ──────────────────────────────────────────────────── Button( onClick = { if (sats != null && isAmountValid) { vm.fetchLnurlInvoice( - rawRes = state.rawRes, - lnurl = state.lnurl, - domain = state.domain, + rawRes = state.rawRes, + lnurl = state.lnurl, + domain = state.domain, amountMsat = sats * WalletConstants.MSAT_PER_SAT, - comment = comment.takeIf { it.isNotBlank() }, - strings = sendStrings, + comment = comment.takeIf { it.isNotBlank() }, + strings = sendStrings, contactId = existingContact?.id ) } @@ -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( ) } } - diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/CurrencyFormatter.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/CurrencyFormatter.kt index 8245912..c892f5e 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/CurrencyFormatter.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/CurrencyFormatter.kt @@ -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" - val dotIdx = raw.indexOf('.') - if (dotIdx == -1) return "~$symbol$raw" +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${localiseDecimal(raw)}" - val decimals = raw.substring(dotIdx + 1) // e.g. "0000431000" - val firstSigIdx = decimals.indexOfFirst { it != '0' } - if (firstSigIdx == -1) return "${symbol}0.00" // effectively zero + val decimals = raw.substring(dotIdx + 1) + val firstSigIdx = decimals.indexOfFirst { it != '0' } - // 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