diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt new file mode 100644 index 000000000..a5215ba4e --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt @@ -0,0 +1,141 @@ +package to.bitkit.ui.screens.wallets.send + +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.junit.Rule +import org.junit.Test +import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PrimaryDisplay +import to.bitkit.viewmodels.CurrencyUiState +import to.bitkit.viewmodels.MainUiState +import to.bitkit.viewmodels.SendEvent +import to.bitkit.viewmodels.SendMethod +import to.bitkit.viewmodels.SendUiState + +class SendAmountContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testUiState = SendUiState( + payMethod = SendMethod.LIGHTNING, + amountInput = "100", + isAmountInputValid = true, + isUnified = true + ) + + private val testWalletState = MainUiState( + nodeLifecycleState = NodeLifecycleState.Running + ) + + @Test + fun whenScreenLoaded_shouldShowAllComponents() { + composeTestRule.setContent { + SendAmountContent( + input = "100", + uiState = testUiState, + walletUiState = testWalletState, + currencyUiState = CurrencyUiState(primaryDisplay = PrimaryDisplay.BITCOIN), + onInputChanged = {}, + onEvent = {}, + onBack = {} + ) + } + + composeTestRule.onNodeWithTag("send_amount_screen").assertExists() +// composeTestRule.onNodeWithTag("amount_input_field").assertExists() doesn't displayed because of viewmodel injection + composeTestRule.onNodeWithTag("available_balance").assertExists() + composeTestRule.onNodeWithTag("payment_method_button").assertExists() + composeTestRule.onNodeWithTag("continue_button").assertExists() + composeTestRule.onNodeWithTag("amount_keyboard").assertExists() + } + + @Test + fun whenNodeNotRunning_shouldShowSyncView() { + composeTestRule.setContent { + SendAmountContent( + input = "100", + uiState = testUiState, + walletUiState = MainUiState( + nodeLifecycleState = NodeLifecycleState.Initializing + ), + currencyUiState = CurrencyUiState(), + onInputChanged = {}, + onEvent = {}, + onBack = {} + ) + } + + composeTestRule.onNodeWithTag("sync_node_view").assertExists() + composeTestRule.onNodeWithTag("amount_input_field").assertDoesNotExist() + } + + @Test + fun whenPaymentMethodButtonClicked_shouldTriggerEvent() { + var eventTriggered = false + composeTestRule.setContent { + SendAmountContent( + input = "100", + uiState = testUiState, + walletUiState = testWalletState, + currencyUiState = CurrencyUiState(), + onInputChanged = {}, + onEvent = { event -> + if (event is SendEvent.PaymentMethodSwitch) { + eventTriggered = true + } + }, + onBack = {} + ) + } + + composeTestRule.onNodeWithTag("payment_method_button") + .performClick() + + assert(eventTriggered) + } + + @Test + fun whenContinueButtonClicked_shouldTriggerEvent() { + var eventTriggered = false + composeTestRule.setContent { + SendAmountContent( + input = "100", + uiState = testUiState.copy(isAmountInputValid = true), + walletUiState = testWalletState, + currencyUiState = CurrencyUiState(), + onInputChanged = {}, + onEvent = { event -> + if (event is SendEvent.AmountContinue) { + eventTriggered = true + } + }, + onBack = {} + ) + } + + composeTestRule.onNodeWithTag("continue_button") + .performClick() + + assert(eventTriggered) + } + + @Test + fun whenAmountInvalid_continueButtonShouldBeDisabled() { + composeTestRule.setContent { + SendAmountContent( + input = "100", + uiState = testUiState.copy(isAmountInputValid = false), + walletUiState = testWalletState, + currencyUiState = CurrencyUiState(), + onInputChanged = {}, + onEvent = {}, + onBack = {} + ) + } + + composeTestRule.onNodeWithTag("continue_button").assertIsNotEnabled() + } +} diff --git a/app/src/main/java/to/bitkit/models/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index 7718b310d..668a68b74 100644 --- a/app/src/main/java/to/bitkit/models/Currency.kt +++ b/app/src/main/java/to/bitkit/models/Currency.kt @@ -7,6 +7,8 @@ import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Locale +const val BITCOIN_SYMBOL = "₿" + @Serializable data class FxRateResponse( val tickers: List, @@ -57,7 +59,7 @@ data class ConvertedAmount( ) fun bitcoinDisplay(unit: BitcoinDisplayUnit): BitcoinDisplayComponents { - val symbol = "₿" + val symbol = BITCOIN_SYMBOL val spaceSeparator = ' ' val formattedValue = when (unit) { BitcoinDisplayUnit.MODERN -> { diff --git a/app/src/main/java/to/bitkit/services/CurrencyService.kt b/app/src/main/java/to/bitkit/services/CurrencyService.kt index 2ca66d8e6..f8d206db0 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -5,12 +5,10 @@ import to.bitkit.async.ServiceQueue import to.bitkit.data.BlocktankHttpClient import to.bitkit.models.ConvertedAmount import to.bitkit.models.FxRate +import to.bitkit.ui.utils.formatCurrency import to.bitkit.utils.AppError import java.math.BigDecimal import java.math.RoundingMode -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols -import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.math.pow @@ -57,15 +55,7 @@ class CurrencyService @Inject constructor( val btcAmount = BigDecimal(sats).divide(BigDecimal(100_000_000)) val value: BigDecimal = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) - val symbols = DecimalFormatSymbols(Locale.getDefault()).apply { - decimalSeparator = '.' - } - val formatter = DecimalFormat("#,##0.00", symbols).apply { - minimumFractionDigits = 2 - maximumFractionDigits = 2 - } - - val formatted = runCatching { formatter.format(value) }.getOrNull() ?: return null + val formatted = value.formatCurrency() ?: return null return ConvertedAmount( value = value, diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index 389049c01..d400bc553 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -27,8 +27,11 @@ fun MoneyDisplay( } @Composable -fun MoneySSB(sats: Long) { - rememberMoneyText(sats)?.let { text -> +fun MoneySSB( + sats: Long, + reversed: Boolean = false, +) { + rememberMoneyText(sats = sats, reversed = reversed)?.let { text -> BodySSB(text = text.withAccent(accentColor = Colors.White64)) } } @@ -62,16 +65,20 @@ fun MoneyCaptionB( } } + + /** * Generates a formatted representation of a monetary value based on the provided amount in satoshis * and the current currency display settings. Can be either in bitcoin or fiat. * * @param sats The amount in satoshis to be formatted and displayed. + * @param reversed If true, swaps the primary and secondary display. Defaults to false. * @return A formatted string representation of the monetary value, or null if it cannot be generated. */ @Composable fun rememberMoneyText( sats: Long, + reversed: Boolean = false, ): String? { val isPreview = LocalInspectionMode.current if (isPreview) { @@ -81,10 +88,17 @@ fun rememberMoneyText( val currency = currencyViewModel ?: return null val currencies = LocalCurrencies.current - return remember(currencies, sats) { + return remember(currencies, sats, reversed) { val converted = currency.convert(sats) ?: return@remember null - if (currencies.primaryDisplay == PrimaryDisplay.BITCOIN) { + val secondaryDisplay = when(currencies.primaryDisplay) { + PrimaryDisplay.BITCOIN -> PrimaryDisplay.FIAT + PrimaryDisplay.FIAT -> PrimaryDisplay.BITCOIN + } + + val primary = if (reversed) secondaryDisplay else currencies.primaryDisplay + + if (primary == PrimaryDisplay.BITCOIN) { val btcComponents = converted.bitcoinDisplay(currencies.displayUnit) "${btcComponents.symbol} ${btcComponents.value}" } else { diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt new file mode 100644 index 000000000..c52a65cda --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt @@ -0,0 +1,281 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import okhttp3.internal.toLongOrDefault +import to.bitkit.ext.removeSpaces +import to.bitkit.models.BITCOIN_SYMBOL +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.formatToModernDisplay +import to.bitkit.ui.currencyViewModel +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + + +@Composable +fun NumberPadTextField( + input: String, + displayUnit: BitcoinDisplayUnit, + primaryDisplay: PrimaryDisplay, + modifier: Modifier = Modifier, +) { + val currency = currencyViewModel ?: return + + val satoshis = if (primaryDisplay == PrimaryDisplay.FIAT) { + currency.convertFiatToSats(fiatAmount = input.replace(",", "").toDoubleOrNull() ?: 0.0) + .toString() + } else { + input.removeSpaces() + } + + var placeholder: String by remember { mutableStateOf("0") } + var placeholderFractional: String by remember { mutableStateOf("") } + var value: String by remember { mutableStateOf("") } + + LaunchedEffect(displayUnit, primaryDisplay) { + placeholderFractional = when { + displayUnit == BitcoinDisplayUnit.CLASSIC -> "00000000" + primaryDisplay == PrimaryDisplay.FIAT -> "00" + else -> "" + } + + placeholder = if (placeholderFractional.isNotEmpty()) { + if (input.contains(".") || primaryDisplay == PrimaryDisplay.FIAT) { + "0.$placeholderFractional" + } else { + ".$placeholderFractional" + } + } else { + "0" + } + + value = "" + } + + if (input.isNotEmpty()) { + val parts = input.split(".") + val whole = parts.firstOrNull().orEmpty().removeSpaces() + val fraction = parts.getOrNull(1).orEmpty().removeSpaces() + + value = when { + primaryDisplay == PrimaryDisplay.FIAT -> { + if (input.contains(".")) { + "$whole.$fraction" + } else { + whole + } + } + + displayUnit == BitcoinDisplayUnit.MODERN && primaryDisplay == PrimaryDisplay.BITCOIN -> { + input.toLongOrDefault(0L).formatToModernDisplay() + } + + else -> { + whole + } + } + + placeholder = when { + input.contains(".") -> { + if (fraction.length < placeholderFractional.length) { + placeholderFractional.drop(fraction.length) + } else { + "" + } + } + + displayUnit == BitcoinDisplayUnit.MODERN && primaryDisplay == PrimaryDisplay.BITCOIN -> "" + else -> if (placeholderFractional.isNotEmpty()) ".$placeholderFractional" else "" + } + } else { + value = "" + } + + MoneyAmount( + modifier = modifier, + value = value, + unit = primaryDisplay, + placeholder = placeholder, + showPlaceholder = true, + satoshis = satoshis.toLongOrNull() ?: 0, + currencySymbol = currency.getCurrencySymbol() + ) +} + +@Composable +fun MoneyAmount( + modifier: Modifier = Modifier, + value: String, + unit: PrimaryDisplay, + placeholder: String, + showPlaceholder: Boolean, + satoshis: Long, + currencySymbol: String, +) { + Column( + modifier = modifier.semantics { contentDescription = value }, + horizontalAlignment = Alignment.Start + ) { + + MoneySSB(sats = satoshis, reversed = true) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Display( + text = if (unit == PrimaryDisplay.BITCOIN) BITCOIN_SYMBOL else currencySymbol, + color = Colors.White64, + modifier = Modifier.padding(end = 6.dp) + ) + + Display( + text = if (value != placeholder) value else "", + color = Colors.White, + ) + + Display( + text = placeholder, + color = if (showPlaceholder) Colors.White50 else Colors.White, + ) + } + } +} + + +@Preview(name = "FIAT - Empty", group = "MoneyAmount", showBackground = true) +@Composable +fun PreviewMoneyAmountFiatEmpty() { + AppThemeSurface { + MoneyAmount( + value = "", + unit = PrimaryDisplay.FIAT, + placeholder = ".00", + showPlaceholder = true, + satoshis = 0, + currencySymbol = "$" + ) + } +} + +@Preview(name = "FIAT - With Value", group = "MoneyAmount", showBackground = true) +@Composable +fun PreviewMoneyAmountFiatWithValue() { + AppThemeSurface { + MoneyAmount( + value = "125.50", + unit = PrimaryDisplay.FIAT, + placeholder = "", + showPlaceholder = true, + satoshis = 12550000000, + currencySymbol = "$" + ) + } +} + +@Preview(name = "BITCOIN - Modern Empty", group = "MoneyAmount", showBackground = true) +@Composable +fun PreviewMoneyAmountBitcoinModernEmpty() { + AppThemeSurface { + MoneyAmount( + value = "", + unit = PrimaryDisplay.BITCOIN, + placeholder = ".00000000", + showPlaceholder = true, + satoshis = 0, + currencySymbol = "₿" + ) + } +} + +@Preview(name = "BITCOIN - Modern With Value", group = "MoneyAmount", showBackground = true) +@Composable +fun PreviewMoneyAmountBitcoinModernWithValue() { + AppThemeSurface { + MoneyAmount( + value = "1.25", + unit = PrimaryDisplay.BITCOIN, + placeholder = "00000", + showPlaceholder = true, + satoshis = 125000000, + currencySymbol = "₿" + ) + } +} + +@Preview(name = "BITCOIN - Classic Empty", group = "MoneyAmount", showBackground = true) +@Composable +fun PreviewMoneyAmountBitcoinClassicEmpty() { + AppThemeSurface { + MoneyAmount( + value = "", + unit = PrimaryDisplay.BITCOIN, + placeholder = ".00000000", + showPlaceholder = true, + satoshis = 0, + currencySymbol = "₿" + ) + } +} + +@Preview(name = "BITCOIN - Classic With Value", group = "MoneyAmount", showBackground = true) +@Composable +fun PreviewMoneyAmountBitcoinClassicWithValue() { + AppThemeSurface { + MoneyAmount( + value = "125000000", + unit = PrimaryDisplay.BITCOIN, + placeholder = "", + showPlaceholder = true, + satoshis = 125000000, + currencySymbol = "₿" + ) + } +} + +@Preview(name = "FIAT - Partial Input", group = "MoneyAmount", showBackground = true) +@Composable +fun PreviewMoneyAmountFiatPartial() { + AppThemeSurface { + MoneyAmount( + value = "125.", + unit = PrimaryDisplay.FIAT, + placeholder = "00", + showPlaceholder = true, + satoshis = 12500000000, + currencySymbol = "$" + ) + } +} + +@Preview(name = "BITCOIN - Partial Input", group = "MoneyAmount", showBackground = true) +@Composable +fun PreviewMoneyAmountBitcoinPartial() { + AppThemeSurface { + MoneyAmount( + value = "1.25", + unit = PrimaryDisplay.BITCOIN, + placeholder = "00000", + showPlaceholder = true, + satoshis = 125000000, + currencySymbol = "₿" + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index 66f2dba89..313c331b5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -10,34 +10,45 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import okhttp3.internal.toLongOrDefault import to.bitkit.R +import to.bitkit.models.BalanceState +import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PrimaryDisplay import to.bitkit.ui.LocalBalances import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.Keyboard import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.OutlinedColorButton import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.UnitButton +import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.CurrencyUiState +import to.bitkit.viewmodels.CurrencyViewModel import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState +import java.math.BigDecimal @Composable fun SendAmountScreen( @@ -46,122 +57,279 @@ fun SendAmountScreen( currencyUiState: CurrencyUiState = LocalCurrencies.current, onBack: () -> Unit, onEvent: (SendEvent) -> Unit, +) { + val currencyVM = currencyViewModel ?: return + var input: String by remember { mutableStateOf("") } + + AmountInputHandler( + input = input, + primaryDisplay = currencyUiState.primaryDisplay, + displayUnit = currencyUiState.displayUnit, + onInputChanged = { newInput -> input = newInput }, + onAmountCalculated = { sats -> onEvent(SendEvent.AmountChange(value = sats)) }, + currencyVM = currencyVM + ) + + SendAmountContent( + input = input, + uiState = uiState, + walletUiState = walletUiState, + currencyUiState = currencyUiState, + primaryDisplay = currencyUiState.primaryDisplay, + displayUnit = currencyUiState.displayUnit, + onInputChanged = { input = it }, + onEvent = onEvent, + onBack = onBack + ) + + +} + +@Composable +fun SendAmountContent( + input: String, + walletUiState: MainUiState, + uiState: SendUiState, + balances: BalanceState = LocalBalances.current, + primaryDisplay: PrimaryDisplay, + displayUnit: BitcoinDisplayUnit, + currencyUiState: CurrencyUiState, + onInputChanged: (String) -> Unit, + onEvent: (SendEvent) -> Unit, + onBack: () -> Unit, ) { Column( modifier = Modifier .fillMaxSize() .gradientBackground() + .testTag("send_amount_screen") ) { SheetTopBar(stringResource(R.string.title_send_amount)) { onEvent(SendEvent.AmountReset) onBack() } - if (walletUiState.nodeLifecycleState is NodeLifecycleState.Running) { - Spacer(Modifier.height(16.dp)) + when (walletUiState.nodeLifecycleState) { + is NodeLifecycleState.Running -> { + SendAmountNodeRunning( + input = input, + uiState = uiState, + currencyUiState = currencyUiState, + onInputChanged = onInputChanged, + balances = balances, + displayUnit = displayUnit, + primaryDisplay = primaryDisplay, + onEvent = onEvent + ) + } - Column( - modifier = Modifier.padding(horizontal = 16.dp) - ) { - BalanceHeaderView(sats = uiState.amountInput.toLongOrDefault(0), modifier = Modifier.fillMaxWidth()) + else -> { + SyncNodeView( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .testTag("sync_node_view") + ) + } + } + } +} - Spacer(modifier = Modifier.height(24.dp)) - Spacer(modifier = Modifier.weight(1f)) +@Composable +private fun SendAmountNodeRunning( + input: String, + uiState: SendUiState, + balances: BalanceState, + primaryDisplay: PrimaryDisplay, + displayUnit: BitcoinDisplayUnit, + currencyUiState: CurrencyUiState, + onInputChanged: (String) -> Unit, + onEvent: (SendEvent) -> Unit, +) { + val availableAmount = when (uiState.payMethod) { + SendMethod.ONCHAIN -> balances.totalOnchainSats.toLong() + SendMethod.LIGHTNING -> balances.totalLightningSats.toLong() + } - Text13Up( - text = stringResource(R.string.wallet__send_available), - color = Colors.White64, - ) - Spacer(modifier = Modifier.height(4.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - val balances = LocalBalances.current - val availableAmount = when (uiState.payMethod) { - SendMethod.ONCHAIN -> balances.totalOnchainSats.toLong() - SendMethod.LIGHTNING -> balances.totalLightningSats.toLong() - } - MoneySSB(sats = availableAmount.toLong()) - - Spacer(modifier = Modifier.weight(1f)) - - OutlinedColorButton( - onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, - enabled = uiState.isUnified, - color = when (uiState.payMethod) { - SendMethod.ONCHAIN -> Colors.Brand - SendMethod.LIGHTNING -> Colors.Purple - }, - modifier = Modifier.height(28.dp) - ) { - Text13Up( - text = when (uiState.payMethod) { - SendMethod.ONCHAIN -> stringResource(R.string.savings) - SendMethod.LIGHTNING -> stringResource(R.string.spending) - }, - color = when (uiState.payMethod) { - SendMethod.ONCHAIN -> Colors.Brand - SendMethod.LIGHTNING -> Colors.Purple - } - ) - } - Spacer(modifier = Modifier.width(8.dp)) - UnitButton( - modifier = Modifier.height(28.dp) - ) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 24.dp)) - - Keyboard( - onClick = { number -> onEvent(SendEvent.AmountChange(number)) }, - onClickBackspace = { onEvent(SendEvent.BackSpaceClick) }, - isDecimal = currencyUiState.primaryDisplay == PrimaryDisplay.FIAT, - modifier = Modifier.fillMaxWidth(), - ) + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Spacer(Modifier.height(16.dp)) - Spacer(modifier = Modifier.height(41.dp)) + NumberPadTextField(input = input, + displayUnit = displayUnit, + primaryDisplay = primaryDisplay, + modifier = Modifier.fillMaxWidth().testTag("amount_input_field")) - PrimaryButton( - text = stringResource(R.string.continue_button), - enabled = uiState.isAmountInputValid, - onClick = { onEvent(SendEvent.AmountContinue(uiState.amountInput)) }, - ) + Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.weight(1f)) + + Text13Up( + text = stringResource(R.string.wallet__send_available), + color = Colors.White64, + modifier = Modifier.testTag("available_balance") + ) + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + MoneySSB(sats = availableAmount.toLong()) + + Spacer(modifier = Modifier.weight(1f)) + + PaymentMethodButton(uiState = uiState, onEvent = onEvent) + Spacer(modifier = Modifier.width(8.dp)) + UnitButton(modifier = Modifier.height(28.dp)) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 24.dp)) + + Keyboard( + onClick = { number -> + onInputChanged(if (input == "0") number else input + number) + }, + onClickBackspace = { + onInputChanged(if (input.length > 1) input.dropLast(1) else "0") + }, + isDecimal = currencyUiState.primaryDisplay == PrimaryDisplay.FIAT, + modifier = Modifier.fillMaxWidth().testTag("amount_keyboard"), + ) + + Spacer(modifier = Modifier.height(41.dp)) - Spacer(modifier = Modifier.height(16.dp)) + PrimaryButton( + text = stringResource(R.string.continue_button), + enabled = uiState.isAmountInputValid, + onClick = { onEvent(SendEvent.AmountContinue(uiState.amountInput)) }, + modifier = Modifier.testTag("continue_button") + ) + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun PaymentMethodButton( + uiState: SendUiState, + onEvent: (SendEvent) -> Unit, +) { + OutlinedColorButton( + onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, + enabled = uiState.isUnified, + color = when (uiState.payMethod) { + SendMethod.ONCHAIN -> Colors.Brand + SendMethod.LIGHTNING -> Colors.Purple + }, + modifier = Modifier.height(28.dp).testTag("payment_method_button") + ) { + Text13Up( + text = when (uiState.payMethod) { + SendMethod.ONCHAIN -> stringResource(R.string.savings) + SendMethod.LIGHTNING -> stringResource(R.string.spending) + }, + color = when (uiState.payMethod) { + SendMethod.ONCHAIN -> Colors.Brand + SendMethod.LIGHTNING -> Colors.Purple + } + ) + } +} + +@Composable +private fun AmountInputHandler( + input: String, + primaryDisplay: PrimaryDisplay, + displayUnit: BitcoinDisplayUnit, + onInputChanged: (String) -> Unit, + onAmountCalculated: (String) -> Unit, + currencyVM: CurrencyViewModel +) { + LaunchedEffect(primaryDisplay) { + val newInput = when (primaryDisplay) { + PrimaryDisplay.BITCOIN -> { //Convert fiat to sats + val amountLong = currencyVM.convertFiatToSats(input.replace(",", "").toDoubleOrNull() ?: 0.0) ?: 0 + if (amountLong > 0.0) amountLong.toString() else "" + } + + PrimaryDisplay.FIAT -> { //Convert sats to fiat + val convertedAmount = currencyVM.convert(input.toLongOrDefault(0L)) + if ((convertedAmount?.value ?: BigDecimal(0)) > BigDecimal(0)) convertedAmount?.formatted.toString() else "" } - } else { - SyncNodeView(modifier = Modifier - .fillMaxWidth() - .weight(1f)) } + onInputChanged(newInput) + } + + LaunchedEffect(input) { + val sats = when (primaryDisplay) { + PrimaryDisplay.BITCOIN -> { + if (displayUnit == BitcoinDisplayUnit.MODERN) input else (input.toLongOrDefault(0L) * 100_000_000).toString() + } + + PrimaryDisplay.FIAT -> { + val convertedAmount = currencyVM.convertFiatToSats(input.replace(",", "").toDoubleOrNull() ?: 0.0) ?: 0L + convertedAmount.toString() + } + } + onAmountCalculated(sats) } } -@Preview(showBackground = true) +@Preview(showBackground = true, name = "Running - Lightning") @Composable -private fun Preview1() { +private fun PreviewRunningLightning() { AppThemeSurface { - SendAmountScreen( + SendAmountContent( uiState = SendUiState( payMethod = SendMethod.LIGHTNING, - amountInput = "100" + amountInput = "100", + isAmountInputValid = true, + isUnified = true + ), + walletUiState = MainUiState( + nodeLifecycleState = NodeLifecycleState.Running + ), + onBack = {}, + onEvent = {}, + input = "100", + displayUnit = BitcoinDisplayUnit.MODERN, + primaryDisplay = PrimaryDisplay.FIAT, + currencyUiState = CurrencyUiState(), + onInputChanged = {} + ) + } +} + +@Preview(showBackground = true, name = "Running - Onchain") +@Composable +private fun PreviewRunningOnchain() { + AppThemeSurface { + SendAmountContent( + uiState = SendUiState( + payMethod = SendMethod.ONCHAIN, + amountInput = "5000", + isAmountInputValid = true, + isUnified = true ), walletUiState = MainUiState( nodeLifecycleState = NodeLifecycleState.Running ), onBack = {}, onEvent = {}, + input = "5000", + currencyUiState = CurrencyUiState(), + displayUnit = BitcoinDisplayUnit.MODERN, + primaryDisplay = PrimaryDisplay.BITCOIN, + onInputChanged = {} ) } } -@Preview(showBackground = true) +@Preview(showBackground = true, name = "Initializing") @Composable -private fun Preview2() { +private fun PreviewInitializing() { AppThemeSurface { - SendAmountScreen( + SendAmountContent( uiState = SendUiState( payMethod = SendMethod.LIGHTNING, amountInput = "100" @@ -171,6 +339,11 @@ private fun Preview2() { ), onBack = {}, onEvent = {}, + displayUnit = BitcoinDisplayUnit.MODERN, + primaryDisplay = PrimaryDisplay.BITCOIN, + input = "100", + currencyUiState = CurrencyUiState(), + onInputChanged = {} ) } } diff --git a/app/src/main/java/to/bitkit/ui/utils/Text.kt b/app/src/main/java/to/bitkit/ui/utils/Text.kt index 9888702d7..811267de3 100644 --- a/app/src/main/java/to/bitkit/ui/utils/Text.kt +++ b/app/src/main/java/to/bitkit/ui/utils/Text.kt @@ -13,6 +13,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.withStyle import to.bitkit.ui.theme.Colors +import java.math.BigDecimal +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale fun String.withAccent( defaultColor: Color = Color.Unspecified, @@ -122,3 +126,18 @@ fun localizedRandom(@StringRes id: Int): String { } } } + +fun BigDecimal.formatCurrency() : String? { + val symbols = DecimalFormatSymbols(Locale.getDefault()).apply { + decimalSeparator = '.' + groupingSeparator = ',' + } + val formatter = DecimalFormat("#,##0.00", symbols).apply { + minimumFractionDigits = 2 + maximumFractionDigits = 2 + } + + return runCatching { formatter.format(this) }.getOrNull() +} + + diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 02bac84bd..0eaf5da91 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -235,7 +235,6 @@ class AppViewModel @Inject constructor( SendEvent.SpeedAndFee -> toast(Exception("Coming soon: Speed and Fee")) SendEvent.SwipeToPay -> onPay() - SendEvent.BackSpaceClick -> onClickBackspace() } } } @@ -277,21 +276,10 @@ class AppViewModel @Inject constructor( } private fun onAmountChange(value: String) { - val newInput = if (_sendUiState.value.amountInput == "0") value else _sendUiState.value.amountInput + value _sendUiState.update { it.copy( - amountInput = newInput, - isAmountInputValid = validateAmount(newInput) - ) - } - } - - private fun onClickBackspace() { - val newInput = if (_sendUiState.value.amountInput.length <= 1) "0" else _sendUiState.value.amountInput.dropLast(1) - _sendUiState.update { - it.copy( - amountInput = newInput, - isAmountInputValid = validateAmount(newInput) + amountInput = value, + isAmountInputValid = validateAmount(value) ) } } @@ -786,7 +774,6 @@ sealed class SendEvent { data object AmountReset : SendEvent() data class AmountContinue(val amount: String) : SendEvent() data class AmountChange(val value: String) : SendEvent() - data object BackSpaceClick : SendEvent() data object SwipeToPay : SendEvent() data object SpeedAndFee : SendEvent() diff --git a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt index 0bb02ef06..fcc118315 100644 --- a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt @@ -159,6 +159,12 @@ class CurrencyViewModel @Inject constructor( } } + fun getCurrencySymbol(): String { + val currentState = uiState.value + return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: "" + } + + // UI Helpers fun convert(sats: Long, currency: String? = null): ConvertedAmount? { val targetCurrency = currency ?: uiState.value.selectedCurrency