From 802a1837b46279c315ad5351cf8b1eacedb7da36 Mon Sep 17 00:00:00 2001 From: Tanishq Singh Date: Tue, 8 Sep 2026 21:35:02 +0530 Subject: [PATCH 1/2] (chore): show fiat value alongside UTXO amounts --- .../org/bitcoinppl/cove/CoinControlManager.kt | 9 ++ .../flows/CoinControlFlow/UtxoListScreen.kt | 17 +++- .../java/org/bitcoinppl/cove_core/cove.kt | 69 +++++++++++++ ios/Cove/CoinControlManager.swift | 6 ++ .../CoinControlFlow/UtxoListScreen.swift | 22 ++++- .../Sources/CoveCore/generated/cove.swift | 53 ++++++++++ .../HotWalletCreateScreenLayoutTests.swift | 1 + rust/src/manager/coin_control_manager.rs | 1 + .../manager/coin_control_manager/display.rs | 97 +++++++++++++++++++ rust/src/manager/wallet_manager/display.rs | 4 +- rust/src/wallet/amount_display.rs | 21 +++- 11 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 rust/src/manager/coin_control_manager/display.rs diff --git a/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt index b3c023024..27479df1d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt @@ -211,6 +211,15 @@ class CoinControlManager internal constructor( fun displayAmount(amount: Amount, showUnit: Boolean = true): String = if (showUnit) amount.fmtStringWithUnit(unit) else amount.fmtString(unit) + fun displayAmountWithFiat( + amount: Amount, + prices: PriceResponse?, + currency: FiatCurrency, + ): String = + withRustOr(displayAmount(amount)) { + displayAmountWithFiat(amount, prices, currency) + } + suspend fun reloadLabels() { withRustOrSuspend(Unit) { reloadLabels() diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/CoinControlFlow/UtxoListScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/CoinControlFlow/UtxoListScreen.kt index d50cfb55b..1924835d3 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/CoinControlFlow/UtxoListScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/CoinControlFlow/UtxoListScreen.kt @@ -118,6 +118,8 @@ fun UtxoListScreen( lockStateLoadFailed = manager.lockStateLoadFailed, totalSelectedAmount = manager.totalSelectedAmount, searchQuery = manager.search, + prices = app.prices, + fiatCurrency = app.selectedFiatCurrency, onBack = { app.popRoute() }, onToggleUnit = { manager.dispatch(org.bitcoinppl.cove_core.CoinControlManagerAction.ToggleUnit) @@ -183,6 +185,8 @@ private fun UtxoListScreenContent( lockStateLoadFailed: Boolean, totalSelectedAmount: String, searchQuery: String, + prices: org.bitcoinppl.cove_core.PriceResponse?, + fiatCurrency: org.bitcoinppl.cove_core.FiatCurrency, onBack: () -> Unit, onToggleUnit: () -> Unit, onToggle: (org.bitcoinppl.cove_core.types.Utxo) -> Unit, @@ -352,8 +356,13 @@ private fun UtxoListScreenContent( Column(modifier = Modifier.verticalScroll(rememberScrollState())) { utxos.forEachIndexed { index, utxo -> UtxoItemRow( - manager = manager, utxo = utxo, + amountText = + manager.displayAmountWithFiat( + utxo.amount, + prices, + fiatCurrency, + ), selected = utxo.spendable && selected.contains(utxo.id), onToggle = { onToggle(utxo) }, onSetSpendability = { @@ -461,8 +470,8 @@ private fun UtxoListScreenContent( @OptIn(ExperimentalFoundationApi::class) @Composable private fun UtxoItemRow( - manager: org.bitcoinppl.cove.CoinControlManager, utxo: org.bitcoinppl.cove_core.types.Utxo, + amountText: String, selected: Boolean, onToggle: () -> Unit, onSetSpendability: () -> Unit, @@ -508,10 +517,12 @@ private fun UtxoItemRow( } Column(horizontalAlignment = Alignment.End) { Text( - manager.displayAmount(utxo.amount), + amountText, fontWeight = FontWeight.Normal, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Spacer(Modifier.height(4.dp)) Text( diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index 23bf0826c..a98f78631 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1567,6 +1567,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_rustcoincontrolmanager_utxos( ): Short + external fun uniffi_cove_checksum_method_rustcoincontrolmanager_display_amount_with_fiat( + ): Short external fun uniffi_cove_checksum_method_rustconnectivitymanager_is_connected( ): Short external fun uniffi_cove_checksum_method_rustconnectivitymanager_set_connection_state( @@ -2725,6 +2727,8 @@ internal object UniffiLib { ): RustBufferBitcoinUnit.ByValue external fun uniffi_cove_fn_method_rustcoincontrolmanager_utxos(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_rustcoincontrolmanager_display_amount_with_fiat(`ptr`: Long,`amount`: Long,`prices`: RustBuffer.ByValue,`currency`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_fn_clone_coincontrolmanagerstate(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_coincontrolmanagerstate(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -4614,6 +4618,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_rustcoincontrolmanager_utxos() != 43520.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_rustcoincontrolmanager_display_amount_with_fiat() != 20084.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_rustconnectivitymanager_is_connected() != 47607.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -21365,6 +21372,14 @@ public interface RustCoinControlManagerInterface { fun `utxos`(): List + /** + * Formats a UTXO amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + * + * Falls back to the bitcoin amount on its own when no prices are available, so the + * amount is never followed by empty brackets. + */ + fun `displayAmountWithFiat`(`amount`: Amount, `prices`: PriceResponse?, `currency`: FiatCurrency): kotlin.String + companion object } @@ -21624,6 +21639,28 @@ open class RustCoinControlManager: Disposable, AutoCloseable, RustCoinControlMan + /** + * Formats a UTXO amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + * + * Falls back to the bitcoin amount on its own when no prices are available, so the + * amount is never followed by empty brackets. + */override fun `displayAmountWithFiat`(`amount`: Amount, `prices`: PriceResponse?, `currency`: FiatCurrency): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_rustcoincontrolmanager_display_amount_with_fiat( + it, + + FfiConverterTypeAmount.lower(`amount`), + FfiConverterOptionalTypePriceResponse.lower(`prices`), + FfiConverterTypeFiatCurrency.lower(`currency`),_status) +} + } + ) + } + + + @@ -66743,6 +66780,38 @@ public object FfiConverterOptionalTypeMigration: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PriceResponse? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypePriceResponse.read(buf) + } + + override fun allocationSize(value: PriceResponse?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypePriceResponse.allocationSize(value) + } + } + + override fun write(value: PriceResponse?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypePriceResponse.write(value, buf) + } + } +} + + + + /** * @suppress */ diff --git a/ios/Cove/CoinControlManager.swift b/ios/Cove/CoinControlManager.swift index f80bbfb66..560f0b984 100644 --- a/ios/Cove/CoinControlManager.swift +++ b/ios/Cove/CoinControlManager.swift @@ -177,6 +177,12 @@ private enum CoinControlManagerError: LocalizedError { showUnit ? amount.fmtStringWithUnit(unit: unit) : amount.fmtString(unit: unit) } + func displayAmountWithFiat(_ amount: Amount, prices: PriceResponse?, currency: FiatCurrency) -> String { + guard let rust else { return displayAmount(amount) } + + return rust.displayAmountWithFiat(amount: amount, prices: prices, currency: currency) + } + func reloadLabels() async { guard let rust else { return } diff --git a/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift b/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift index 279450cd0..0ad627b84 100644 --- a/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift +++ b/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift @@ -544,6 +544,8 @@ private struct UtxoListToolbarMenu: View { // MARK: - Row private struct UtxoRow: View { + @Environment(AppManager.self) private var app + var manager: CoinControlManager let utxo: Utxo let onLockedSelectionAttempt: () -> Void @@ -582,18 +584,30 @@ private struct UtxoRow: View { .truncationMode(.middle) } } + // floor keeps the name legible while the address absorbs the squeeze + .frame(minWidth: 100, alignment: .leading) Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 4) { - Text(manager.displayAmount(utxo.amount)) - .font(.footnote) - .fontWeight(.regular) + Text( + manager.displayAmountWithFiat( + utxo.amount, + prices: app.prices, + currency: app.selectedFiatCurrency + ) + ) + .font(.footnote) + .fontWeight(.regular) + .lineLimit(1) + .minimumScaleFactor(0.7) + .allowsTightening(true) Text(utxo.date()) .font(.caption) .foregroundColor(.secondary) } + .layoutPriority(1) } .padding(.vertical, 4) .opacity(utxo.spendable ? 1 : 0.58) @@ -615,6 +629,7 @@ private struct UtxoRow: View { manager: CoinControlManager(RustCoinControlManager.previewNew()) ) .environment(WalletManager(preview: .only)) + .environment(AppManager.shared) } } @@ -626,5 +641,6 @@ private struct UtxoRow: View { ) ) .environment(WalletManager(preview: .only)) + .environment(AppManager.shared) } } diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 70eea32c0..8f59fe7e1 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -8905,6 +8905,14 @@ public protocol RustCoinControlManagerProtocol: AnyObject, Sendable { func utxos() -> [Utxo] + /** + * Formats a UTXO amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + * + * Falls back to the bitcoin amount on its own when no prices are available, so the + * amount is never followed by empty brackets. + */ + func displayAmountWithFiat(amount: Amount, prices: PriceResponse?, currency: FiatCurrency) -> String + } open class RustCoinControlManager: RustCoinControlManagerProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -9079,6 +9087,24 @@ open func utxos() -> [Utxo] { }) } + /** + * Formats a UTXO amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + * + * Falls back to the bitcoin amount on its own when no prices are available, so the + * amount is never followed by empty brackets. + */ +open func displayAmountWithFiat(amount: Amount, prices: PriceResponse?, currency: FiatCurrency) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_rustcoincontrolmanager_display_amount_with_fiat( + self.uniffiCloneHandle(), + FfiConverterTypeAmount_lower(amount), + FfiConverterOptionTypePriceResponse.lower(prices), + FfiConverterTypeFiatCurrency_lower(currency),uniffiCallStatus + ) +}) +} + } @@ -44503,6 +44529,30 @@ fileprivate struct FfiConverterOptionTypeMigration: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypePriceResponse: FfiConverterRustBuffer { + typealias SwiftType = PriceResponse? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypePriceResponse.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypePriceResponse.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -47629,6 +47679,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_rustcoincontrolmanager_utxos() != 43520) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_rustcoincontrolmanager_display_amount_with_fiat() != 20084) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_rustconnectivitymanager_is_connected() != 47607) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift b/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift index acc3912cf..266797d90 100644 --- a/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift +++ b/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift @@ -447,6 +447,7 @@ final class HotWalletCreateScreenLayoutTests: XCTestCase { manager: CoinControlManager(RustCoinControlManager.previewNew()) ) .environment(WalletManager(preview: .only)) + .environment(AppManager.shared) } .frame(width: size.width, height: size.height), size: size diff --git a/rust/src/manager/coin_control_manager.rs b/rust/src/manager/coin_control_manager.rs index ff6cb34a9..c78aca75d 100644 --- a/rust/src/manager/coin_control_manager.rs +++ b/rust/src/manager/coin_control_manager.rs @@ -1,3 +1,4 @@ +mod display; mod state; use std::{ diff --git a/rust/src/manager/coin_control_manager/display.rs b/rust/src/manager/coin_control_manager/display.rs new file mode 100644 index 000000000..3f8d0a750 --- /dev/null +++ b/rust/src/manager/coin_control_manager/display.rs @@ -0,0 +1,97 @@ +use std::sync::Arc; + +use cove_types::amount::Amount; + +use crate::{ + fiat::{FiatCurrency, client::PriceResponse}, + wallet::amount_display, +}; + +use super::RustCoinControlManager; + +#[uniffi::export] +impl RustCoinControlManager { + /// Formats a UTXO amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + /// + /// Falls back to the bitcoin amount on its own when no prices are available, so the + /// amount is never followed by empty brackets. + #[uniffi::method] + pub fn display_amount_with_fiat( + &self, + amount: Arc, + prices: Option>, + currency: FiatCurrency, + ) -> String { + let unit = self.state.lock().unit; + let bitcoin = amount.fmt_string_with_unit(unit); + + let Some(prices) = prices else { return bitcoin }; + + let fiat = amount_display::convert_amount_to_fiat(&amount, &prices, currency); + let fiat = amount_display::fmt_fiat_amount(currency, fiat, true); + + format!("{bitcoin} ({fiat})") + } +} + +#[cfg(test)] +mod tests { + use cove_types::unit::BitcoinUnit; + + use super::*; + + /// 0.0005 BTC (50,000 sats) is worth 31.25 at this price + fn prices() -> Arc { + Arc::new(PriceResponse { + time: 0, + fetched_at: 0, + usd: 62_500, + eur: 62_500, + gbp: 62_500, + cad: 62_500, + chf: 62_500, + aud: 62_500, + jpy: 62_500, + }) + } + + fn manager(unit: BitcoinUnit) -> RustCoinControlManager { + let manager = RustCoinControlManager::preview_new(1, 0); + manager.state.lock().unit = unit; + + manager + } + + #[test] + fn display_amount_with_fiat_appends_fiat_value_in_brackets() { + let manager = manager(BitcoinUnit::Sat); + let amount = Arc::new(Amount::from_sat(50_000)); + + assert_eq!( + manager.display_amount_with_fiat(amount, Some(prices()), FiatCurrency::Usd), + "50,000 SATS ($31.25)" + ); + } + + #[test] + fn display_amount_with_fiat_uses_the_selected_unit_and_currency() { + let manager = manager(BitcoinUnit::Btc); + let amount = Arc::new(Amount::from_sat(50_000)); + + assert_eq!( + manager.display_amount_with_fiat(amount, Some(prices()), FiatCurrency::Chf), + "0.0005 BTC (31.25 CHF)" + ); + } + + #[test] + fn display_amount_with_fiat_omits_brackets_without_prices() { + let manager = manager(BitcoinUnit::Sat); + let amount = Arc::new(Amount::from_sat(50_000)); + + assert_eq!( + manager.display_amount_with_fiat(amount, None, FiatCurrency::Usd), + "50,000 SATS" + ); + } +} diff --git a/rust/src/manager/wallet_manager/display.rs b/rust/src/manager/wallet_manager/display.rs index 6655618d7..e8c99476e 100644 --- a/rust/src/manager/wallet_manager/display.rs +++ b/rust/src/manager/wallet_manager/display.rs @@ -111,8 +111,8 @@ impl RustWalletManager { #[uniffi::method] pub fn convert_to_fiat(&self, amount: Arc, prices: Arc) -> f64 { let currency = self.selected_fiat_currency(); - let price = prices.get_for_currency(currency) as f64; - ((amount.as_btc() * price) * 100.0).ceil() / 100.0 + + amount_display::convert_amount_to_fiat(&amount, &prices, currency) } #[uniffi::method(default(with_suffix = true))] diff --git a/rust/src/wallet/amount_display.rs b/rust/src/wallet/amount_display.rs index 17f932a56..2bd42e109 100644 --- a/rust/src/wallet/amount_display.rs +++ b/rust/src/wallet/amount_display.rs @@ -4,7 +4,10 @@ use cove_util::format::NumberFormatter as _; use crate::{ database::Database, - fiat::{FiatCurrency, client::FIAT_CLIENT}, + fiat::{ + FiatCurrency, + client::{FIAT_CLIENT, PriceResponse}, + }, transaction::{Amount, SentAndReceived, TransactionDirection, Unit}, wallet::metadata::WalletMetadata, }; @@ -144,6 +147,22 @@ pub(crate) fn display_fiat_amount_with_currency( return FIAT_MASK.to_string(); } + fmt_fiat_amount(currency, amount, with_suffix) +} + +/// Converts a bitcoin amount into its fiat value, rounded up to the nearest cent +pub(crate) fn convert_amount_to_fiat( + amount: &Amount, + prices: &PriceResponse, + currency: FiatCurrency, +) -> f64 { + let price = prices.get_for_currency(currency) as f64; + + ((amount.as_btc() * price) * 100.0).ceil() / 100.0 +} + +/// Formats a fiat value with its currency symbol and suffix, e.g. "$31.25" or "31.25 CHF" +pub(crate) fn fmt_fiat_amount(currency: FiatCurrency, amount: f64, with_suffix: bool) -> String { let fiat = amount.thousands_fiat(); let symbol = currency.symbol(); let suffix = currency.suffix(); From a0eb407b269d23e190de46e78a278b0ef99e5688 Mon Sep 17 00:00:00 2001 From: Tanishq Singh Date: Sat, 26 Sep 2026 00:41:18 +0530 Subject: [PATCH 2/2] (chore): move UTXO fiat value to its own line, add fiat to selected total Signed-off-by: Tanishq Singh --- .../org/bitcoinppl/cove/CoinControlManager.kt | 12 +++-- .../flows/CoinControlFlow/UtxoListScreen.kt | 41 +++++++++++--- .../java/org/bitcoinppl/cove_core/cove.kt | 49 ++++++++++++++--- ios/Cove/CoinControlManager.swift | 8 +-- .../CoinControlFlow/UtxoListScreen.swift | 42 +++++++++------ .../Sources/CoveCore/generated/cove.swift | 41 +++++++++++--- .../manager/coin_control_manager/display.rs | 54 +++++++++++++++---- 7 files changed, 192 insertions(+), 55 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt index 27479df1d..859ac2a57 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt @@ -136,9 +136,6 @@ class CoinControlManager internal constructor( buttonPresentation(key) } - val totalSelectedAmount: String - get() = displayAmount(totalSelected) - val totalSelectedSats: Long get() = totalSelected.asSats().toLong() @@ -220,6 +217,15 @@ class CoinControlManager internal constructor( displayAmountWithFiat(amount, prices, currency) } + fun displayFiatAmount( + amount: Amount, + prices: PriceResponse?, + currency: FiatCurrency, + ): String? = + withRustOr(null) { + displayFiatAmount(amount, prices, currency) + } + suspend fun reloadLabels() { withRustOrSuspend(Unit) { reloadLabels() diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/CoinControlFlow/UtxoListScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/CoinControlFlow/UtxoListScreen.kt index 1924835d3..b876368d9 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/CoinControlFlow/UtxoListScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/CoinControlFlow/UtxoListScreen.kt @@ -116,7 +116,12 @@ fun UtxoListScreen( utxos = manager.utxos, selected = manager.selected, lockStateLoadFailed = manager.lockStateLoadFailed, - totalSelectedAmount = manager.totalSelectedAmount, + totalSelectedAmount = + manager.displayAmountWithFiat( + manager.totalSelected, + app.prices, + app.selectedFiatCurrency, + ), searchQuery = manager.search, prices = app.prices, fiatCurrency = app.selectedFiatCurrency, @@ -357,11 +362,15 @@ private fun UtxoListScreenContent( utxos.forEachIndexed { index, utxo -> UtxoItemRow( utxo = utxo, - amountText = - manager.displayAmountWithFiat( - utxo.amount, - prices, - fiatCurrency, + amount = + UtxoRowAmount( + bitcoin = manager.displayAmount(utxo.amount), + fiat = + manager.displayFiatAmount( + utxo.amount, + prices, + fiatCurrency, + ), ), selected = utxo.spendable && selected.contains(utxo.id), onToggle = { onToggle(utxo) }, @@ -467,11 +476,17 @@ private fun UtxoListScreenContent( } } +// fiat is null when no prices are available, so the row hides the fiat line +private data class UtxoRowAmount( + val bitcoin: String, + val fiat: String?, +) + @OptIn(ExperimentalFoundationApi::class) @Composable private fun UtxoItemRow( utxo: org.bitcoinppl.cove_core.types.Utxo, - amountText: String, + amount: UtxoRowAmount, selected: Boolean, onToggle: () -> Unit, onSetSpendability: () -> Unit, @@ -517,13 +532,23 @@ private fun UtxoItemRow( } Column(horizontalAlignment = Alignment.End) { Text( - amountText, + amount.bitcoin, fontWeight = FontWeight.Normal, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + amount.fiat?.let { fiat -> + Spacer(Modifier.height(4.dp)) + Text( + fiat, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } Spacer(Modifier.height(4.dp)) Text( utxo.displayDate, diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index a98f78631..1b6acda5e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1569,6 +1569,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_rustcoincontrolmanager_display_amount_with_fiat( ): Short + external fun uniffi_cove_checksum_method_rustcoincontrolmanager_display_fiat_amount( + ): Short external fun uniffi_cove_checksum_method_rustconnectivitymanager_is_connected( ): Short external fun uniffi_cove_checksum_method_rustconnectivitymanager_set_connection_state( @@ -2729,6 +2731,8 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_cove_fn_method_rustcoincontrolmanager_display_amount_with_fiat(`ptr`: Long,`amount`: Long,`prices`: RustBuffer.ByValue,`currency`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_rustcoincontrolmanager_display_fiat_amount(`ptr`: Long,`amount`: Long,`prices`: RustBuffer.ByValue,`currency`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_fn_clone_coincontrolmanagerstate(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_coincontrolmanagerstate(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -4618,7 +4622,10 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_rustcoincontrolmanager_utxos() != 43520.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_rustcoincontrolmanager_display_amount_with_fiat() != 20084.toShort()) { + if (lib.uniffi_cove_checksum_method_rustcoincontrolmanager_display_amount_with_fiat() != 29447.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_rustcoincontrolmanager_display_fiat_amount() != 8420.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_checksum_method_rustconnectivitymanager_is_connected() != 47607.toShort()) { @@ -21373,13 +21380,20 @@ public interface RustCoinControlManagerInterface { fun `utxos`(): List /** - * Formats a UTXO amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + * Formats an amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") * - * Falls back to the bitcoin amount on its own when no prices are available, so the - * amount is never followed by empty brackets. + * Used for the selected total. Falls back to the bitcoin amount on its own when no + * prices are available, so the amount is never followed by empty brackets. */ fun `displayAmountWithFiat`(`amount`: Amount, `prices`: PriceResponse?, `currency`: FiatCurrency): kotlin.String + /** + * Formats the fiat value of an amount on its own (e.g. "$31.25" or "31.25 CHF") + * + * Returns None when no prices are available, so the fiat line can be hidden. + */ + fun `displayFiatAmount`(`amount`: Amount, `prices`: PriceResponse?, `currency`: FiatCurrency): kotlin.String? + companion object } @@ -21640,10 +21654,10 @@ open class RustCoinControlManager: Disposable, AutoCloseable, RustCoinControlMan /** - * Formats a UTXO amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + * Formats an amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") * - * Falls back to the bitcoin amount on its own when no prices are available, so the - * amount is never followed by empty brackets. + * Used for the selected total. Falls back to the bitcoin amount on its own when no + * prices are available, so the amount is never followed by empty brackets. */override fun `displayAmountWithFiat`(`amount`: Amount, `prices`: PriceResponse?, `currency`: FiatCurrency): kotlin.String { return FfiConverterString.lift( callWithHandle { @@ -21661,6 +21675,27 @@ open class RustCoinControlManager: Disposable, AutoCloseable, RustCoinControlMan + /** + * Formats the fiat value of an amount on its own (e.g. "$31.25" or "31.25 CHF") + * + * Returns None when no prices are available, so the fiat line can be hidden. + */override fun `displayFiatAmount`(`amount`: Amount, `prices`: PriceResponse?, `currency`: FiatCurrency): kotlin.String? { + return FfiConverterOptionalString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_rustcoincontrolmanager_display_fiat_amount( + it, + + FfiConverterTypeAmount.lower(`amount`), + FfiConverterOptionalTypePriceResponse.lower(`prices`), + FfiConverterTypeFiatCurrency.lower(`currency`),_status) +} + } + ) + } + + + diff --git a/ios/Cove/CoinControlManager.swift b/ios/Cove/CoinControlManager.swift index 560f0b984..202a0309f 100644 --- a/ios/Cove/CoinControlManager.swift +++ b/ios/Cove/CoinControlManager.swift @@ -137,10 +137,6 @@ private enum CoinControlManagerError: LocalizedError { selection.total } - public var totalSelectedAmount: String { - displayAmount(self.totalSelected) - } - public var totalSelectedSats: Int { Int(self.totalSelected.asSats()) } @@ -183,6 +179,10 @@ private enum CoinControlManagerError: LocalizedError { return rust.displayAmountWithFiat(amount: amount, prices: prices, currency: currency) } + func displayFiatAmount(_ amount: Amount, prices: PriceResponse?, currency: FiatCurrency) -> String? { + rust?.displayFiatAmount(amount: amount, prices: prices, currency: currency) + } + func reloadLabels() async { guard let rust else { return } diff --git a/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift b/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift index 0ad627b84..c32e83521 100644 --- a/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift +++ b/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift @@ -299,12 +299,22 @@ private struct UtxoSortButton: View { } private struct UtxoSelectionSection: View { + @Environment(AppManager.self) private var app + let manager: CoinControlManager let onShowTransaction: (Utxo) -> Void @Binding var showLockedSelectionAlert: Bool @Binding var utxoLockUpdateError: String? + private var totalSelectedAmount: String { + manager.displayAmountWithFiat( + manager.totalSelected, + prices: app.prices, + currency: app.selectedFiatCurrency + ) + } + var body: some View { VStack(spacing: 8) { UtxoSelectionHeader( @@ -328,12 +338,12 @@ private struct UtxoSelectionSection: View { .padding(.horizontal) } - Text(manager.totalSelectedAmount) + Text(totalSelectedAmount) .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) .opacity(manager.selected.isEmpty ? 0 : 0.8) .contentTransition(.numericText()) - .animation(.easeInOut(duration: 0.1), value: manager.totalSelectedAmount) + .animation(.easeInOut(duration: 0.1), value: totalSelectedAmount) } } } @@ -584,30 +594,28 @@ private struct UtxoRow: View { .truncationMode(.middle) } } - // floor keeps the name legible while the address absorbs the squeeze - .frame(minWidth: 100, alignment: .leading) Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 4) { - Text( - manager.displayAmountWithFiat( - utxo.amount, - prices: app.prices, - currency: app.selectedFiatCurrency - ) - ) - .font(.footnote) - .fontWeight(.regular) - .lineLimit(1) - .minimumScaleFactor(0.7) - .allowsTightening(true) + Text(manager.displayAmount(utxo.amount)) + .font(.footnote) + .fontWeight(.regular) + + if let fiat = manager.displayFiatAmount( + utxo.amount, + prices: app.prices, + currency: app.selectedFiatCurrency + ) { + Text(fiat) + .font(.caption) + .foregroundColor(.secondary) + } Text(utxo.date()) .font(.caption) .foregroundColor(.secondary) } - .layoutPriority(1) } .padding(.vertical, 4) .opacity(utxo.spendable ? 1 : 0.58) diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 8f59fe7e1..5a6416baa 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -8906,13 +8906,20 @@ public protocol RustCoinControlManagerProtocol: AnyObject, Sendable { func utxos() -> [Utxo] /** - * Formats a UTXO amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + * Formats an amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") * - * Falls back to the bitcoin amount on its own when no prices are available, so the - * amount is never followed by empty brackets. + * Used for the selected total. Falls back to the bitcoin amount on its own when no + * prices are available, so the amount is never followed by empty brackets. */ func displayAmountWithFiat(amount: Amount, prices: PriceResponse?, currency: FiatCurrency) -> String + /** + * Formats the fiat value of an amount on its own (e.g. "$31.25" or "31.25 CHF") + * + * Returns None when no prices are available, so the fiat line can be hidden. + */ + func displayFiatAmount(amount: Amount, prices: PriceResponse?, currency: FiatCurrency) -> String? + } open class RustCoinControlManager: RustCoinControlManagerProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -9088,10 +9095,10 @@ open func utxos() -> [Utxo] { } /** - * Formats a UTXO amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + * Formats an amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") * - * Falls back to the bitcoin amount on its own when no prices are available, so the - * amount is never followed by empty brackets. + * Used for the selected total. Falls back to the bitcoin amount on its own when no + * prices are available, so the amount is never followed by empty brackets. */ open func displayAmountWithFiat(amount: Amount, prices: PriceResponse?, currency: FiatCurrency) -> String { return try! FfiConverterString.lift(try! rustCall() { @@ -9105,6 +9112,23 @@ open func displayAmountWithFiat(amount: Amount, prices: PriceResponse?, currency }) } + /** + * Formats the fiat value of an amount on its own (e.g. "$31.25" or "31.25 CHF") + * + * Returns None when no prices are available, so the fiat line can be hidden. + */ +open func displayFiatAmount(amount: Amount, prices: PriceResponse?, currency: FiatCurrency) -> String? { + return try! FfiConverterOptionString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_rustcoincontrolmanager_display_fiat_amount( + self.uniffiCloneHandle(), + FfiConverterTypeAmount_lower(amount), + FfiConverterOptionTypePriceResponse.lower(prices), + FfiConverterTypeFiatCurrency_lower(currency),uniffiCallStatus + ) +}) +} + } @@ -47679,7 +47703,10 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_rustcoincontrolmanager_utxos() != 43520) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_rustcoincontrolmanager_display_amount_with_fiat() != 20084) { + if (uniffi_cove_checksum_method_rustcoincontrolmanager_display_amount_with_fiat() != 29447) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_rustcoincontrolmanager_display_fiat_amount() != 8420) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_rustconnectivitymanager_is_connected() != 47607) { diff --git a/rust/src/manager/coin_control_manager/display.rs b/rust/src/manager/coin_control_manager/display.rs index 3f8d0a750..e95f21028 100644 --- a/rust/src/manager/coin_control_manager/display.rs +++ b/rust/src/manager/coin_control_manager/display.rs @@ -11,10 +11,25 @@ use super::RustCoinControlManager; #[uniffi::export] impl RustCoinControlManager { - /// Formats a UTXO amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + /// Formats the fiat value of an amount on its own (e.g. "$31.25" or "31.25 CHF") /// - /// Falls back to the bitcoin amount on its own when no prices are available, so the - /// amount is never followed by empty brackets. + /// Returns None when no prices are available, so the fiat line can be hidden. + #[uniffi::method] + pub fn display_fiat_amount( + &self, + amount: Arc, + prices: Option>, + currency: FiatCurrency, + ) -> Option { + let prices = prices?; + let fiat = amount_display::convert_amount_to_fiat(&amount, &prices, currency); + Some(amount_display::fmt_fiat_amount(currency, fiat, true)) + } + + /// Formats an amount followed by its fiat value in brackets (e.g. "50,000 SATS ($31.25)") + /// + /// Used for the selected total. Falls back to the bitcoin amount on its own when no + /// prices are available, so the amount is never followed by empty brackets. #[uniffi::method] pub fn display_amount_with_fiat( &self, @@ -25,12 +40,10 @@ impl RustCoinControlManager { let unit = self.state.lock().unit; let bitcoin = amount.fmt_string_with_unit(unit); - let Some(prices) = prices else { return bitcoin }; - - let fiat = amount_display::convert_amount_to_fiat(&amount, &prices, currency); - let fiat = amount_display::fmt_fiat_amount(currency, fiat, true); - - format!("{bitcoin} ({fiat})") + match self.display_fiat_amount(amount, prices, currency) { + Some(fiat) => format!("{bitcoin} ({fiat})"), + None => bitcoin, + } } } @@ -62,6 +75,29 @@ mod tests { manager } + #[test] + fn display_fiat_amount_formats_the_selected_currency() { + let manager = manager(BitcoinUnit::Sat); + let amount = Arc::new(Amount::from_sat(50_000)); + + assert_eq!( + manager.display_fiat_amount(amount.clone(), Some(prices()), FiatCurrency::Usd), + Some("$31.25".to_string()) + ); + assert_eq!( + manager.display_fiat_amount(amount, Some(prices()), FiatCurrency::Chf), + Some("31.25 CHF".to_string()) + ); + } + + #[test] + fn display_fiat_amount_is_none_without_prices() { + let manager = manager(BitcoinUnit::Sat); + let amount = Arc::new(Amount::from_sat(50_000)); + + assert_eq!(manager.display_fiat_amount(amount, None, FiatCurrency::Usd), None); + } + #[test] fn display_amount_with_fiat_appends_fiat_value_in_brackets() { let manager = manager(BitcoinUnit::Sat);