From 3638405c375433847451e56dbab5b006ab2fe789 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Sep 2025 14:36:16 +0200 Subject: [PATCH 01/10] refactor: cleanup logic if node not running in receive amount --- .gitignore | 1 + .../wallets/receive/ReceiveAmountScreen.kt | 49 ++++++------------- .../wallets/receive/ReceiveQrScreen.kt | 13 +++-- 3 files changed, 23 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index de2c19d59..c8d9099da 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ CLAUDE.md # Secrets google-services.json +.env *.keystore !debug.keystore keystore.properties diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt index c0cefed4d..e355a6df2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt @@ -26,13 +26,10 @@ import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PrimaryDisplay -import to.bitkit.models.Toast import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.appViewModel import to.bitkit.ui.blocktankViewModel @@ -57,7 +54,6 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.walletViewModel import to.bitkit.utils.Logger import to.bitkit.viewmodels.CurrencyUiState -import kotlin.time.Duration.Companion.milliseconds @Composable fun ReceiveAmountScreen( @@ -111,40 +107,27 @@ fun ReceiveAmountScreen( scope.launch { isCreatingInvoice = true - if (walletState.nodeLifecycleState == NodeLifecycleState.Starting) { - while (walletState.nodeLifecycleState == NodeLifecycleState.Starting && isActive) { - delay(5.milliseconds) + runCatching { + require(walletState.nodeLifecycleState == NodeLifecycleState.Running) { + "Should not be able to land on this screen if the node is not running." } - } - if (walletState.nodeLifecycleState == NodeLifecycleState.Running) { - runCatching { - val entry = blocktank.createCjit(amountSats = sats) - onCjitCreated( - CjitEntryDetails( - networkFeeSat = entry.networkFeeSat.toLong(), - serviceFeeSat = entry.serviceFeeSat.toLong(), - channelSizeSat = entry.channelSizeSat.toLong(), - feeSat = entry.feeSat.toLong(), - receiveAmountSats = satsAmount, - invoice = entry.invoice.request, - ) + val entry = blocktank.createCjit(amountSats = sats) + onCjitCreated( + CjitEntryDetails( + networkFeeSat = entry.networkFeeSat.toLong(), + serviceFeeSat = entry.serviceFeeSat.toLong(), + channelSizeSat = entry.channelSizeSat.toLong(), + feeSat = entry.feeSat.toLong(), + receiveAmountSats = satsAmount, + invoice = entry.invoice.request, ) - }.onFailure { e -> - app.toast(e) - Logger.error("Failed to create CJIT", e) - } - - isCreatingInvoice = false - } else { - // TODO add missing localized texts - app.toast( - type = Toast.ToastType.WARNING, - title = "Lightning not ready", - description = "Lightning node must be running to create an invoice", ) - isCreatingInvoice = false + }.onFailure { e -> + app.toast(e) + Logger.error("Failed to create CJIT", e) } + isCreatingInvoice = false } } ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 980ecb833..0520018b9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -490,16 +489,16 @@ private fun Preview() { } } -@Preview(showSystemUi = true, device = NEXUS_5) +@Preview(showSystemUi = true) @Composable -private fun PreviewSmall() { +private fun PreviewNodeNotReady() { AppThemeSurface { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = remember { mutableStateOf(null) }, cjitActive = remember { mutableStateOf(false) }, walletState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Running, + nodeLifecycleState = NodeLifecycleState.Starting, ), onCjitToggle = {}, onClickEditInvoice = {}, @@ -510,16 +509,16 @@ private fun PreviewSmall() { } } -@Preview(showSystemUi = true, device = Devices.PIXEL_TABLET) +@Preview(showSystemUi = true, device = NEXUS_5) @Composable -private fun PreviewTablet() { +private fun PreviewSmall() { AppThemeSurface { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = remember { mutableStateOf(null) }, cjitActive = remember { mutableStateOf(false) }, walletState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Starting, + nodeLifecycleState = NodeLifecycleState.Running, ), onCjitToggle = {}, onClickEditInvoice = {}, From 3df3b4563029b54dfff0a57c22b811d8b4700184 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 11 Sep 2025 18:52:11 +0200 Subject: [PATCH 02/10] refactor: replace getCurrencySymbol with get from local state --- app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt | 4 ---- app/src/main/java/to/bitkit/ui/ContentView.kt | 2 +- app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt | 2 -- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index bfd971892..a157cf92e 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -177,10 +177,6 @@ class CurrencyRepo @Inject constructor( refresh() } - fun getCurrencySymbol(): String { - return _currencyState.value.currencySymbol - } - fun getCurrentRate(currency: String): FxRate? { return _currencyState.value.rates.firstOrNull { it.quote == currency } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 07e7b76af..3fcc5699a 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1069,7 +1069,7 @@ private fun NavGraphBuilder.widgets( WidgetType.WEATHER -> navController.navigate(Routes.WeatherPreview) } }, - fiatSymbol = currencyViewModel.getCurrencySymbol() + fiatSymbol = LocalCurrencies.current.currencySymbol, ) } composableWithDefaultTransitions { diff --git a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt index 17ae39f11..3f3015d74 100644 --- a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt @@ -49,8 +49,6 @@ class CurrencyViewModel @Inject constructor( } } - fun getCurrencySymbol(): String = currencyRepo.getCurrencySymbol() - // UI Helpers fun convert(sats: Long, currency: String? = null): ConvertedAmount? { return currencyRepo.convertSatsToFiat(sats, currency).getOrNull() From 35f8504935f41703372158fe873d4b95ddaca570 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 11 Sep 2025 19:20:06 +0200 Subject: [PATCH 03/10] refactor: unit button --- .../to/bitkit/ui/components/UnitButton.kt | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/UnitButton.kt b/app/src/main/java/to/bitkit/ui/components/UnitButton.kt index f157a7678..185d37b6b 100644 --- a/app/src/main/java/to/bitkit/ui/components/UnitButton.kt +++ b/app/src/main/java/to/bitkit/ui/components/UnitButton.kt @@ -14,25 +14,21 @@ import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.currencyViewModel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.CurrencyUiState +import to.bitkit.viewmodels.CurrencyViewModel @Composable fun UnitButton( modifier: Modifier = Modifier, - onClick: () -> Unit = {}, color: Color = Colors.Brand, - primaryDisplay: PrimaryDisplay = LocalCurrencies.current.primaryDisplay, + currencies: CurrencyUiState = LocalCurrencies.current, + currency: CurrencyViewModel? = currencyViewModel, + onClick: () -> Unit = { currency?.togglePrimaryDisplay() }, ) { - val currency = currencyViewModel - val currencies = LocalCurrencies.current - val text = if (primaryDisplay == PrimaryDisplay.BITCOIN) "Bitcoin" else currencies.selectedCurrency - NumberPadActionButton( - text = text, + text = if (currencies.primaryDisplay == PrimaryDisplay.BITCOIN) "Bitcoin" else currencies.selectedCurrency, color = color, - onClick = { - currency?.togglePrimaryDisplay() - onClick() - }, + onClick = onClick, icon = R.drawable.ic_transfer, modifier = modifier, ) @@ -46,8 +42,8 @@ private fun Preview() { verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp) ) { - UnitButton(primaryDisplay = PrimaryDisplay.BITCOIN) - UnitButton(primaryDisplay = PrimaryDisplay.FIAT) + UnitButton(currencies = CurrencyUiState(primaryDisplay = PrimaryDisplay.BITCOIN)) + UnitButton(currencies = CurrencyUiState(primaryDisplay = PrimaryDisplay.FIAT)) } } } From 3fe822c0cf87fcd022e90f12ce7e00aaa1db146a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 11 Sep 2025 19:33:50 +0200 Subject: [PATCH 04/10] refactor: currency model --- .../main/java/to/bitkit/models/Currency.kt | 53 ++++++++----------- .../to/bitkit/repositories/CurrencyRepo.kt | 7 ++- .../java/to/bitkit/ui/components/Money.kt | 3 +- .../general/DefaultUnitSettingsScreen.kt | 6 +-- .../BitcoinVisualTransformation.kt | 14 ++--- 5 files changed, 35 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index 541542d4f..47dfaf527 100644 --- a/app/src/main/java/to/bitkit/models/Currency.kt +++ b/app/src/main/java/to/bitkit/models/Currency.kt @@ -11,8 +11,7 @@ import java.util.Locale const val BITCOIN_SYMBOL = "₿" const val SATS_IN_BTC = 100_000_000 const val BTC_SCALE = 8 -const val BTC_PLACEHOLDER = "0.00000000" -const val SATS_PLACEHOLDER = "0" +const val GROUPING_SEPARATOR = ' ' @Serializable data class FxRateResponse( @@ -42,11 +41,9 @@ data class FxRate( enum class PrimaryDisplay { BITCOIN, FIAT; - operator fun not(): PrimaryDisplay { - return when (this) { - BITCOIN -> FIAT - FIAT -> BITCOIN - } + operator fun not() = when (this) { + BITCOIN -> FIAT + FIAT -> BITCOIN } } @@ -54,12 +51,12 @@ enum class PrimaryDisplay { enum class BitcoinDisplayUnit { MODERN, CLASSIC; - operator fun not(): BitcoinDisplayUnit { - return when (this) { - MODERN -> CLASSIC - CLASSIC -> MODERN - } + operator fun not() = when (this) { + MODERN -> CLASSIC + CLASSIC -> MODERN } + + fun isModern() = this == MODERN } data class ConvertedAmount( @@ -69,28 +66,17 @@ data class ConvertedAmount( val currency: String, val flag: String, val sats: Long, + val locale: Locale = Locale.getDefault(), ) { - val btcValue: BigDecimal = sats.asBtc() - data class BitcoinDisplayComponents( val symbol: String, val value: String, ) fun bitcoinDisplay(unit: BitcoinDisplayUnit): BitcoinDisplayComponents { - val spaceSeparator = ' ' val formattedValue = when (unit) { - BitcoinDisplayUnit.MODERN -> { - sats.formatToModernDisplay() - } - - BitcoinDisplayUnit.CLASSIC -> { - val formatSymbols = DecimalFormatSymbols(Locale.getDefault()).apply { - groupingSeparator = spaceSeparator - } - val formatter = DecimalFormat("#,###.########", formatSymbols) - formatter.format(btcValue) - } + BitcoinDisplayUnit.MODERN -> sats.formatToModernDisplay(locale) + BitcoinDisplayUnit.CLASSIC -> sats.formatToClassicDisplay(locale) } return BitcoinDisplayComponents( symbol = BITCOIN_SYMBOL, @@ -99,10 +85,10 @@ data class ConvertedAmount( } } -fun Long.formatToModernDisplay(): String { +fun Long.formatToModernDisplay(locale: Locale = Locale.getDefault()): String { val sats = this - val formatSymbols = DecimalFormatSymbols(Locale.getDefault()).apply { - groupingSeparator = ' ' + val formatSymbols = DecimalFormatSymbols(locale).apply { + groupingSeparator = GROUPING_SEPARATOR } val formatter = DecimalFormat("#,###", formatSymbols).apply { isGroupingUsed = true @@ -110,7 +96,14 @@ fun Long.formatToModernDisplay(): String { return formatter.format(sats) } -fun ULong.formatToModernDisplay(): String = this.toLong().formatToModernDisplay() +fun ULong.formatToModernDisplay(locale: Locale = Locale.getDefault()): String = toLong().formatToModernDisplay(locale) + +fun Long.formatToClassicDisplay(locale: Locale = Locale.getDefault()): String { + val sats = this + val formatSymbols = DecimalFormatSymbols(locale) + val formatter = DecimalFormat("###.########", formatSymbols) + return formatter.format(sats.asBtc()) +} /** Represent this sat value in Bitcoin BigDecimal. */ fun Long.asBtc(): BigDecimal = BigDecimal(this).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index a157cf92e..5008efa2b 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -23,6 +23,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BTC_SCALE import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount @@ -252,5 +253,7 @@ data class CurrencyState( val currencySymbol: String = "$", val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN, - val lastSuccessfulRefresh: Long? = null -) + val lastSuccessfulRefresh: Long? = null, +) { + fun primarySymbol() = if (primaryDisplay == PrimaryDisplay.BITCOIN) BITCOIN_SYMBOL else currencySymbol +} 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 ce2b0cd04..d83935d71 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -115,9 +115,8 @@ fun rememberMoneyText( ): String? { val isPreview = LocalInspectionMode.current if (isPreview) { - val symbol = if (unit == PrimaryDisplay.BITCOIN) BITCOIN_SYMBOL else "$" return buildString { - if (showSymbol) append("$symbol ") + if (showSymbol) append("${currencies.primarySymbol()} ") append(sats.formatToModernDisplay()) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt index 9c46591a4..850bddce1 100644 --- a/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt @@ -93,7 +93,7 @@ fun DefaultUnitSettingsScreenContent( BitcoinDisplayUnit.entries.forEach { unit -> SettingsButtonRow( title = stringResource( - if (unit == BitcoinDisplayUnit.MODERN) { + if (unit.isModern()) { R.string.settings__general__denomination_modern } else { R.string.settings__general__denomination_classic @@ -101,9 +101,7 @@ fun DefaultUnitSettingsScreenContent( ), value = SettingsButtonValue.BooleanValue(displayUnit == unit), onClick = { onBitcoinUnitClick(unit) }, - modifier = Modifier.testTag( - if (unit == BitcoinDisplayUnit.MODERN) "DenominationModern" else "DenominationClassic" - ) + modifier = Modifier.testTag(if (unit.isModern()) "DenominationModern" else "DenominationClassic") ) } diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt index 8a610eab6..c647c7446 100644 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt +++ b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt @@ -5,6 +5,8 @@ import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.GROUPING_SEPARATOR +import to.bitkit.models.formatToModernDisplay import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Locale @@ -34,16 +36,8 @@ class BitcoinVisualTransformation( } private fun formatModernDisplay(text: String): String { - val cleanText = text.replace(" ", "") - val longValue = cleanText.toLongOrNull() ?: return text - - val formatSymbols = DecimalFormatSymbols(Locale.getDefault()).apply { - groupingSeparator = ' ' - } - val formatter = DecimalFormat("#,###", formatSymbols).apply { - isGroupingUsed = true - } - return formatter.format(longValue) + val longValue = text.replace("$GROUPING_SEPARATOR", "").toLongOrNull() ?: return text + return longValue.formatToModernDisplay() } private fun formatClassicDisplay(text: String): String { From 75ba7636530547f85dad1675403a0652b797c52e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 11 Sep 2025 23:50:50 +0200 Subject: [PATCH 05/10] refactor: custom numberpad --- .../to/bitkit/ui/components/KeyboardTest.kt | 90 -- .../wallets/send/SendAmountContentTest.kt | 78 +- app/src/main/java/to/bitkit/di/RepoModule.kt | 18 +- .../main/java/to/bitkit/models/Currency.kt | 36 +- .../to/bitkit/repositories/CurrencyRepo.kt | 108 +- .../java/to/bitkit/repositories/WalletRepo.kt | 8 +- app/src/main/java/to/bitkit/ui/Locals.kt | 4 +- .../to/bitkit/ui/components/AmountInput.kt | 2 +- .../bitkit/ui/components/BalanceHeaderView.kt | 4 +- .../java/to/bitkit/ui/components/Keyboard.kt | 228 ---- .../java/to/bitkit/ui/components/Money.kt | 19 +- .../java/to/bitkit/ui/components/NumberPad.kt | 291 ++++++ .../bitkit/ui/components/NumberPadSimple.kt | 21 +- .../ui/components/NumberPadTextField.kt | 353 ++----- .../to/bitkit/ui/components/UnitButton.kt | 12 +- .../screens/transfer/SavingsAdvancedScreen.kt | 2 +- .../screens/transfer/SavingsConfirmScreen.kt | 2 +- .../screens/transfer/SpendingAmountScreen.kt | 119 ++- .../wallets/receive/EditInvoiceScreen.kt | 131 +-- .../wallets/receive/ReceiveAmountScreen.kt | 91 +- .../screens/wallets/receive/ReceiveSheet.kt | 9 +- .../screens/wallets/send/SendAmountScreen.kt | 239 ++--- app/src/main/java/to/bitkit/ui/utils/Text.kt | 19 - .../BitcoinVisualTransformation.kt | 4 +- .../CalculatorFormatter.kt | 5 +- .../bitkit/viewmodels/AmountInputViewModel.kt | 420 ++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 33 +- .../to/bitkit/viewmodels/CurrencyViewModel.kt | 7 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 83 +- .../to/bitkit/viewmodels/WalletViewModel.kt | 6 - .../viewmodels/AmountInputViewModelTest.kt | 975 ++++++++++++++++++ config/detekt/detekt.yml | 4 +- 32 files changed, 2211 insertions(+), 1210 deletions(-) delete mode 100644 app/src/androidTest/java/to/bitkit/ui/components/KeyboardTest.kt delete mode 100644 app/src/main/java/to/bitkit/ui/components/Keyboard.kt create mode 100644 app/src/main/java/to/bitkit/ui/components/NumberPad.kt create mode 100644 app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt create mode 100644 app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/components/KeyboardTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/KeyboardTest.kt deleted file mode 100644 index 493e63bd9..000000000 --- a/app/src/androidTest/java/to/bitkit/ui/components/KeyboardTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package to.bitkit.ui.components - -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@HiltAndroidTest -class KeyboardTest { - - @get:Rule - val composeTestRule = createComposeRule() - - @get:Rule - val hiltRule = HiltAndroidRule(this) - - @Before - fun setup() { - hiltRule.inject() - } - - @Test - fun keyboard_displaysAllButtons() { - composeTestRule.setContent { - Keyboard(onClick = {}, onClickBackspace = {}) - } - - composeTestRule.onNodeWithTag("N1").assertIsDisplayed() - composeTestRule.onNodeWithTag("N2").assertIsDisplayed() - composeTestRule.onNodeWithTag("N3").assertIsDisplayed() - composeTestRule.onNodeWithTag("N4").assertIsDisplayed() - composeTestRule.onNodeWithTag("N5").assertIsDisplayed() - composeTestRule.onNodeWithTag("N6").assertIsDisplayed() - composeTestRule.onNodeWithTag("N7").assertIsDisplayed() - composeTestRule.onNodeWithTag("N8").assertIsDisplayed() - composeTestRule.onNodeWithTag("N9").assertIsDisplayed() - composeTestRule.onNodeWithTag("N.").assertIsDisplayed() - composeTestRule.onNodeWithTag("N0").assertIsDisplayed() - composeTestRule.onNodeWithTag("NRemove").assertIsDisplayed() - } - - @Test - fun keyboard_tripleZero_when_not_decimal() { - composeTestRule.setContent { - Keyboard(onClick = {}, isDecimal = false, onClickBackspace = {}) - } - composeTestRule.onNodeWithTag("N000").assertIsDisplayed() - } - - @Test - fun keyboard_decimal_when_decimal() { - composeTestRule.setContent { - Keyboard(onClick = {}, isDecimal = true, onClickBackspace = {}) - } - composeTestRule.onNodeWithTag("N.").assertIsDisplayed() - } - - @Test - fun keyboard_button_click_triggers_callback() { - var clickedValue = "" - composeTestRule.setContent { - Keyboard(onClick = { clickedValue = it }, onClickBackspace = {}) - } - - composeTestRule.onNodeWithTag("N5").performClick() - assert(clickedValue == "5") - - composeTestRule.onNodeWithTag("N.").performClick() - assert(clickedValue == ".") - - composeTestRule.onNodeWithTag("N0").performClick() - assert(clickedValue == "0") - - } - - @Test - fun keyboard_button_click_tripleZero() { - var clickedValue = "" - composeTestRule.setContent { - Keyboard(onClick = { clickedValue = it }, onClickBackspace = {}, isDecimal = false) - } - - composeTestRule.onNodeWithTag("N000").performClick() - assert(clickedValue == "000") - } - -} 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 index 840632ef6..cf0cb569f 100644 --- 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 @@ -6,14 +6,13 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test -import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PrimaryDisplay -import to.bitkit.viewmodels.CurrencyUiState +import to.bitkit.repositories.CurrencyState import to.bitkit.viewmodels.MainUiState -import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState +import to.bitkit.viewmodels.previewAmountInputViewModel class SendAmountContentTest { @@ -22,8 +21,7 @@ class SendAmountContentTest { private val testUiState = SendUiState( payMethod = SendMethod.LIGHTNING, - amountInput = "100", - isAmountInputValid = true, + amount = 100u, isUnified = true ) @@ -35,21 +33,15 @@ class SendAmountContentTest { fun whenScreenLoaded_shouldShowAllComponents() { composeTestRule.setContent { SendAmountContent( - input = "100", - uiState = testUiState, walletUiState = testWalletState, - currencyUiState = CurrencyUiState(primaryDisplay = PrimaryDisplay.BITCOIN), - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.BITCOIN, - onInputChanged = {}, - onEvent = {}, - onBack = {} + uiState = testUiState, + amountInputViewModel = previewAmountInputViewModel(), ) } 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("SendNumberField").assertExists() + composeTestRule.onNodeWithTag("available_balance", useUnmergedTree = true).assertExists() composeTestRule.onNodeWithTag("AssetButton-switch").assertExists() composeTestRule.onNodeWithTag("ContinueAmount").assertExists() composeTestRule.onNodeWithTag("SendAmountNumberPad").assertExists() @@ -59,22 +51,16 @@ class SendAmountContentTest { fun whenNodeNotRunning_shouldShowSyncView() { composeTestRule.setContent { SendAmountContent( - input = "100", - uiState = testUiState, walletUiState = MainUiState( nodeLifecycleState = NodeLifecycleState.Initializing ), - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.BITCOIN, - currencyUiState = CurrencyUiState(), - onInputChanged = {}, - onEvent = {}, - onBack = {} + uiState = testUiState, + amountInputViewModel = previewAmountInputViewModel(), ) } composeTestRule.onNodeWithTag("sync_node_view").assertExists() - composeTestRule.onNodeWithTag("amount_input_field").assertDoesNotExist() + composeTestRule.onNodeWithTag("SendNumberField").assertDoesNotExist() } @Test @@ -82,19 +68,10 @@ class SendAmountContentTest { var eventTriggered = false composeTestRule.setContent { SendAmountContent( - input = "100", - uiState = testUiState, walletUiState = testWalletState, - currencyUiState = CurrencyUiState(), - onInputChanged = {}, - onEvent = { event -> - if (event is SendEvent.PaymentMethodSwitch) { - eventTriggered = true - } - }, - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.BITCOIN, - onBack = {} + uiState = testUiState, + amountInputViewModel = previewAmountInputViewModel(), + onClickPayMethod = { eventTriggered = true } ) } @@ -109,23 +86,14 @@ class SendAmountContentTest { 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 - } - }, - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.BITCOIN, - onBack = {} + uiState = testUiState, + amountInputViewModel = previewAmountInputViewModel(), + onContinue = { eventTriggered = true } ) } - composeTestRule.onNodeWithTag("continue_button") + composeTestRule.onNodeWithTag("ContinueAmount") .performClick() assert(eventTriggered) @@ -135,18 +103,12 @@ class SendAmountContentTest { fun whenAmountInvalid_continueButtonShouldBeDisabled() { composeTestRule.setContent { SendAmountContent( - input = "100", - uiState = testUiState.copy(isAmountInputValid = false), walletUiState = testWalletState, - currencyUiState = CurrencyUiState(), - onInputChanged = {}, - onEvent = {}, - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.BITCOIN, - onBack = {} + uiState = testUiState.copy(amount = 0u), + amountInputViewModel = previewAmountInputViewModel(), ) } - composeTestRule.onNodeWithTag("continue_button").assertIsNotEnabled() + composeTestRule.onNodeWithTag("ContinueAmount").assertIsNotEnabled() } } diff --git a/app/src/main/java/to/bitkit/di/RepoModule.kt b/app/src/main/java/to/bitkit/di/RepoModule.kt index 445f8dc4d..6cbcccb25 100644 --- a/app/src/main/java/to/bitkit/di/RepoModule.kt +++ b/app/src/main/java/to/bitkit/di/RepoModule.kt @@ -1,16 +1,26 @@ package to.bitkit.di +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import to.bitkit.repositories.AmountInputHandler +import to.bitkit.repositories.CurrencyRepo import javax.inject.Named @Module @InstallIn(SingletonComponent::class) -object RepoModule { +abstract class RepoModule { - @Provides - @Named("enablePolling") - fun provideEnablePolling(): Boolean = true + @Suppress("unused") + @Binds + abstract fun bindAmountInputHandler(currencyRepo: CurrencyRepo): AmountInputHandler + + companion object { + @Suppress("FunctionOnlyReturningConstant") + @Provides + @Named("enablePolling") + fun provideEnablePolling(): Boolean = true + } } diff --git a/app/src/main/java/to/bitkit/models/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index 47dfaf527..1fa15b7ff 100644 --- a/app/src/main/java/to/bitkit/models/Currency.kt +++ b/app/src/main/java/to/bitkit/models/Currency.kt @@ -8,10 +8,16 @@ import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Locale +const val STUB_RATE = 115_150.0 const val BITCOIN_SYMBOL = "₿" +const val USD_SYMBOL = "$" const val SATS_IN_BTC = 100_000_000 const val BTC_SCALE = 8 -const val GROUPING_SEPARATOR = ' ' +const val SATS_GROUPING_SEPARATOR = ' ' +const val FIAT_GROUPING_SEPARATOR = ',' +const val DECIMAL_SEPARATOR = '.' +const val CLASSIC_DECIMALS = 8 +const val FIAT_DECIMALS = 2 @Serializable data class FxRateResponse( @@ -87,10 +93,10 @@ data class ConvertedAmount( fun Long.formatToModernDisplay(locale: Locale = Locale.getDefault()): String { val sats = this - val formatSymbols = DecimalFormatSymbols(locale).apply { - groupingSeparator = GROUPING_SEPARATOR + val symbols = DecimalFormatSymbols(locale).apply { + groupingSeparator = SATS_GROUPING_SEPARATOR } - val formatter = DecimalFormat("#,###", formatSymbols).apply { + val formatter = DecimalFormat("#,###", symbols).apply { isGroupingUsed = true } return formatter.format(sats) @@ -100,10 +106,28 @@ fun ULong.formatToModernDisplay(locale: Locale = Locale.getDefault()): String = fun Long.formatToClassicDisplay(locale: Locale = Locale.getDefault()): String { val sats = this - val formatSymbols = DecimalFormatSymbols(locale) - val formatter = DecimalFormat("###.########", formatSymbols) + val symbols = DecimalFormatSymbols(locale).apply { + decimalSeparator = DECIMAL_SEPARATOR + } + val formatter = DecimalFormat("###.########", symbols) return formatter.format(sats.asBtc()) } +fun BigDecimal.formatCurrency(decimalPlaces: Int = FIAT_DECIMALS, locale: Locale = Locale.getDefault()): String? { + val symbols = DecimalFormatSymbols(locale).apply { + decimalSeparator = DECIMAL_SEPARATOR + groupingSeparator = FIAT_GROUPING_SEPARATOR + } + + val decimalPlacesString = "0".repeat(decimalPlaces) + val formatter = DecimalFormat("#,##0.$decimalPlacesString", symbols).apply { + minimumFractionDigits = decimalPlaces + maximumFractionDigits = decimalPlaces + isGroupingUsed = true + } + + return runCatching { formatter.format(this) }.getOrNull() +} + /** Represent this sat value in Bitcoin BigDecimal. */ fun Long.asBtc(): BigDecimal = BigDecimal(this).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 5008efa2b..8de9d3458 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -23,18 +23,18 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.env.Env -import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BTC_SCALE import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount import to.bitkit.models.FxRate import to.bitkit.models.PrimaryDisplay import to.bitkit.models.SATS_IN_BTC +import to.bitkit.models.STUB_RATE import to.bitkit.models.Toast import to.bitkit.models.asBtc +import to.bitkit.models.formatCurrency import to.bitkit.services.CurrencyService import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.ui.utils.formatCurrency import to.bitkit.utils.Logger import java.math.BigDecimal import java.math.RoundingMode @@ -42,6 +42,7 @@ import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton +@Suppress("TooManyFunctions") @Singleton class CurrencyRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, @@ -50,7 +51,7 @@ class CurrencyRepo @Inject constructor( private val cacheStore: CacheStore, @Named("enablePolling") private val enablePolling: Boolean, private val clock: Clock, -) { +) : AmountInputHandler { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) private val _currencyState = MutableStateFlow(CurrencyState()) val currencyState: StateFlow = _currencyState.asStateFlow() @@ -154,14 +155,13 @@ class CurrencyRepo @Inject constructor( } } - suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) { - settingsStore.update { settings -> - val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) { - PrimaryDisplay.FIAT - } else { - PrimaryDisplay.BITCOIN - } - settings.copy(primaryDisplay = newDisplay) + suspend fun switchUnit() = withContext(bgDispatcher) { + settingsStore.update { it.copy(primaryDisplay = _currencyState.value.primaryDisplay.not()) } + } + + override suspend fun switchUnit(unit: PrimaryDisplay): PrimaryDisplay = withContext(bgDispatcher) { + unit.not().also { nextValue -> + setPrimaryDisplayUnit(nextValue) } } @@ -178,8 +178,13 @@ class CurrencyRepo @Inject constructor( refresh() } - fun getCurrentRate(currency: String): FxRate? { - return _currencyState.value.rates.firstOrNull { it.quote == currency } + fun getCurrentRate(currency: String): FxRate { + val rates = _currencyState.value.rates + val rate = rates.firstOrNull { it.quote == currency } + + return checkNotNull(rate) { + "Rate not found for currency: $currency in: ${rates.joinToString { it.quote }}" + } } fun convertSatsToFiat( @@ -187,21 +192,9 @@ class CurrencyRepo @Inject constructor( currency: String? = null, ): Result = runCatching { val targetCurrency = currency ?: _currencyState.value.selectedCurrency - val rate = getCurrentRate(targetCurrency) ?: return Result.failure( - IllegalStateException( - "Rate not found for currency: $targetCurrency. Available currencies: ${ - _currencyState.value.rates.joinToString { it.quote } - }" - ) - ) + val rate = getCurrentRate(targetCurrency) - val btcAmount = sats.asBtc() - val fiatValue = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) - val formatted = fiatValue.formatCurrency() ?: return Result.failure( - IllegalStateException( - "Failed to format value: $fiatValue for currency: $targetCurrency" - ) - ) + val (fiatValue, formatted) = convertSatsToFiatPair(sats, targetCurrency).getOrThrow() ConvertedAmount( value = fiatValue, @@ -213,16 +206,28 @@ class CurrencyRepo @Inject constructor( ) } + fun convertSatsToFiatPair( + sats: Long, + currency: String? = null, + ): Result> = runCatching { + val targetCurrency = currency ?: _currencyState.value.selectedCurrency + val rate = getCurrentRate(targetCurrency) + + val btcAmount = sats.asBtc() + val fiatValue = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) + val formatted = checkNotNull(fiatValue.formatCurrency()) { + "Failed to format value: $fiatValue for currency: $targetCurrency" + } + + return@runCatching fiatValue to formatted + } + fun convertFiatToSats( fiatValue: BigDecimal, currency: String? = null, ): Result = runCatching { val targetCurrency = currency ?: _currencyState.value.selectedCurrency - val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException( - "Rate not found for currency: $targetCurrency. Available currencies: ${ - _currencyState.value.rates.joinToString { it.quote } - }" - ) + val rate = getCurrentRate(targetCurrency) val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP) val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC)) @@ -230,19 +235,16 @@ class CurrencyRepo @Inject constructor( roundedSats.toLong().toULong() } - fun convertFiatToSats( - fiatAmount: Double, - currency: String? - ): Result { - return convertFiatToSats( - fiatValue = BigDecimal.valueOf(fiatAmount), - currency = currency - ) - } + fun convertFiatToSats(fiat: Double, currency: String?) = convertFiatToSats(BigDecimal.valueOf(fiat), currency) companion object { private const val TAG = "CurrencyRepo" } + + // MARK: - AmountHandler + + override fun convertFiatToSats(fiat: Double) = convertFiatToSats(BigDecimal.valueOf(fiat)).getOrDefault(0u).toLong() + override fun convertSatsToFiatString(sats: Long): String = convertSatsToFiatPair(sats).getOrNull()?.second ?: "" } data class CurrencyState( @@ -254,6 +256,26 @@ data class CurrencyState( val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN, val lastSuccessfulRefresh: Long? = null, -) { - fun primarySymbol() = if (primaryDisplay == PrimaryDisplay.BITCOIN) BITCOIN_SYMBOL else currencySymbol +) + +interface AmountInputHandler { + fun convertSatsToFiatString(sats: Long): String + fun convertFiatToSats(fiat: Double): Long + suspend fun switchUnit(unit: PrimaryDisplay): PrimaryDisplay + + companion object { + fun stub(state: CurrencyState = CurrencyState()) = object : AmountInputHandler { + private var currentPrimaryDisplay = state.primaryDisplay + + override fun convertSatsToFiatString(sats: Long): String { + return sats.asBtc().multiply(BigDecimal.valueOf(STUB_RATE)).formatCurrency() ?: "" + } + + override fun convertFiatToSats(fiat: Double) = (fiat / STUB_RATE * SATS_IN_BTC).toLong() + override suspend fun switchUnit(unit: PrimaryDisplay): PrimaryDisplay { + currentPrimaryDisplay = currentPrimaryDisplay.not() + return currentPrimaryDisplay + } + } + } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index fd9ae9a0b..a3a3649ec 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -113,8 +113,7 @@ class WalletRepo @Inject constructor( it.copy( selectedTags = emptyList(), bip21Description = "", - balanceInput = "", - bip21 = "" + bip21 = "", ) } @@ -347,10 +346,6 @@ class WalletRepo @Inject constructor( _walletState.update { it.copy(bip21Description = description) } } - fun updateBalanceInput(newText: String) { - _walletState.update { it.copy(balanceInput = newText) } - } - suspend fun toggleReceiveOnSpendingBalance(): Result = withContext(bgDispatcher) { if (!_walletState.value.receiveOnSpendingBalance && coreService.shouldBlockLightning()) { return@withContext Result.failure(ServiceError.GeoBlocked) @@ -525,7 +520,6 @@ class WalletRepo @Inject constructor( data class WalletState( val onchainAddress: String = "", - val balanceInput: String = "", val bolt11: String = "", val bip21: String = "", val bip21AmountSats: ULong? = null, diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index 018fa65e9..60dbef5c6 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -4,11 +4,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf import to.bitkit.models.BalanceState +import to.bitkit.repositories.CurrencyState import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.BackupsViewModel import to.bitkit.viewmodels.BlocktankViewModel -import to.bitkit.viewmodels.CurrencyUiState import to.bitkit.viewmodels.CurrencyViewModel import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.TransferViewModel @@ -16,7 +16,7 @@ import to.bitkit.viewmodels.WalletViewModel // Locals val LocalBalances = compositionLocalOf { BalanceState() } -val LocalCurrencies = compositionLocalOf { CurrencyUiState() } +val LocalCurrencies = compositionLocalOf { CurrencyState() } // Statics val LocalAppViewModel = staticCompositionLocalOf { null } diff --git a/app/src/main/java/to/bitkit/ui/components/AmountInput.kt b/app/src/main/java/to/bitkit/ui/components/AmountInput.kt index 93aef3b0a..b290d2744 100644 --- a/app/src/main/java/to/bitkit/ui/components/AmountInput.kt +++ b/app/src/main/java/to/bitkit/ui/components/AmountInput.kt @@ -193,7 +193,7 @@ fun AmountInput( // Visible balance display currency.convert(sats)?.let { converted -> Column( - modifier = modifier.clickableAlpha { currency.togglePrimaryDisplay() } + modifier = modifier.clickableAlpha { currency.switchUnit() } ) { if (showConversion) { val captionText = if (primaryDisplay == PrimaryDisplay.BITCOIN) { diff --git a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt index e8cafe737..dadc59b58 100644 --- a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt +++ b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt @@ -93,7 +93,7 @@ fun BalanceHeaderView( hideBalance = shouldHideBalance, isSwipeToHideEnabled = allowSwipeToHide, showEyeIcon = showEyeIcon, - onClick = onClick ?: { currency.togglePrimaryDisplay() }, + onClick = onClick ?: { currency.switchUnit() }, onToggleHideBalance = { settings.setHideBalance(!hideBalance) }, testTag = testTag, ) @@ -111,7 +111,7 @@ fun BalanceHeaderView( hideBalance = shouldHideBalance, isSwipeToHideEnabled = allowSwipeToHide, showEyeIcon = showEyeIcon, - onClick = { currency.togglePrimaryDisplay() }, + onClick = { currency.switchUnit() }, onToggleHideBalance = { settings.setHideBalance(!hideBalance) }, testTag = testTag, ) diff --git a/app/src/main/java/to/bitkit/ui/components/Keyboard.kt b/app/src/main/java/to/bitkit/ui/components/Keyboard.kt deleted file mode 100644 index 3ae652c12..000000000 --- a/app/src/main/java/to/bitkit/ui/components/Keyboard.kt +++ /dev/null @@ -1,228 +0,0 @@ -package to.bitkit.ui.components - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import to.bitkit.R -import to.bitkit.ui.shared.util.clickableAlpha -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors - -private val maxKeyboardHeight = 300.dp -private val idealButtonHeight = 75.dp -private val minButtonHeight = 50.dp -private const val KEYBOARD_ROWS_NUMBER = 4 -private const val KEYBOARD_COLUMNS_NUMBER = 3 -val keyButtonHaptic = HapticFeedbackType.VirtualKey - -@Composable -fun Keyboard( - onClick: (String) -> Unit, - onClickBackspace: () -> Unit, - modifier: Modifier = Modifier, - isDecimal: Boolean = true, - availableHeight: Dp? = null, -) { - BoxWithConstraints(modifier = modifier) { - val constraintsHeight = this.maxHeight - val effectiveHeight = availableHeight ?: constraintsHeight - val idealTotalHeight = idealButtonHeight * KEYBOARD_ROWS_NUMBER - - val maxAllowedHeight = minOf(maxKeyboardHeight, effectiveHeight) - - val buttonHeight = when { - // If we have plenty of space, use ideal height - maxAllowedHeight >= idealTotalHeight -> idealButtonHeight - // If space is limited, calculate proportional height but ensure minimum - maxAllowedHeight >= (minButtonHeight * KEYBOARD_ROWS_NUMBER) -> maxAllowedHeight / KEYBOARD_ROWS_NUMBER - // If extremely limited, use absolute minimum - else -> minButtonHeight - } - - val totalKeyboardHeight = buttonHeight * KEYBOARD_ROWS_NUMBER - - LazyVerticalGrid( - columns = GridCells.Fixed(KEYBOARD_COLUMNS_NUMBER), - userScrollEnabled = false, - modifier = Modifier.height(totalKeyboardHeight), - ) { - item { KeyTextButton(text = "1", onClick = onClick, buttonHeight = buttonHeight) } - item { KeyTextButton(text = "2", onClick = onClick, buttonHeight = buttonHeight) } - item { KeyTextButton(text = "3", onClick = onClick, buttonHeight = buttonHeight) } - item { KeyTextButton(text = "4", onClick = onClick, buttonHeight = buttonHeight) } - item { KeyTextButton(text = "5", onClick = onClick, buttonHeight = buttonHeight) } - item { KeyTextButton(text = "6", onClick = onClick, buttonHeight = buttonHeight) } - item { KeyTextButton(text = "7", onClick = onClick, buttonHeight = buttonHeight) } - item { KeyTextButton(text = "8", onClick = onClick, buttonHeight = buttonHeight) } - item { KeyTextButton(text = "9", onClick = onClick, buttonHeight = buttonHeight) } - item { - KeyTextButton( - text = if (isDecimal) "." else "000", - onClick = onClick, - buttonHeight = buttonHeight, - testTag = if (isDecimal) "NDecimal" else "N000", - ) - } - item { KeyTextButton(text = "0", onClick = onClick, buttonHeight = buttonHeight) } - item { - KeyIconButton( - icon = R.drawable.ic_backspace, - contentDescription = stringResource(R.string.common__delete), - onClick = onClickBackspace, - buttonHeight = buttonHeight, - modifier = Modifier.testTag("NRemove"), - ) - } - } - } -} - -@Composable -fun KeyIconButton( - @DrawableRes icon: Int, - contentDescription: String?, - onClick: () -> Unit, - modifier: Modifier = Modifier, - buttonHeight: Dp = idealButtonHeight, -) { - KeyButtonBox( - onClick = onClick, - buttonHeight = buttonHeight, - modifier = modifier, - ) { - Icon( - painter = painterResource(icon), - contentDescription = contentDescription, - ) - } -} - -@Composable -fun KeyTextButton( - text: String, - onClick: (String) -> Unit, - buttonHeight: Dp = idealButtonHeight, - modifier: Modifier = Modifier, - testTag: String = "N$text", -) { - KeyButtonBox( - onClick = { onClick(text) }, - buttonHeight = buttonHeight, - modifier = modifier.testTag(testTag) - ) { - Text( - text = text, - fontSize = when { - buttonHeight < 60.dp -> 20.sp - buttonHeight < 70.dp -> 22.sp - else -> 24.sp - }, - textAlign = TextAlign.Center, - color = Colors.White, - ) - } -} - -@Composable -private fun KeyButtonBox( - onClick: () -> Unit, - buttonHeight: Dp, - modifier: Modifier = Modifier, - content: @Composable (BoxScope.() -> Unit), -) { - val haptic = LocalHapticFeedback.current - Box( - content = content, - contentAlignment = Alignment.Center, - modifier = modifier - .height(buttonHeight) - .fillMaxWidth() - .clickableAlpha(0.2f) { - haptic.performHapticFeedback(keyButtonHaptic) - onClick() - }, - ) -} - -@Preview(showBackground = true) -@Composable -private fun Preview() { - AppThemeSurface { - Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { - Keyboard( - onClick = {}, - onClickBackspace = {}, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} - -@Preview(showBackground = true) -@Composable -private fun Preview2() { - AppThemeSurface { - Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { - Keyboard( - isDecimal = false, - onClick = {}, - onClickBackspace = {}, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} - -@Preview(showBackground = true, device = Devices.PIXEL_TABLET) -@Composable -private fun Preview3() { - AppThemeSurface { - Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { - Keyboard( - isDecimal = false, - onClick = {}, - onClickBackspace = {}, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} - -@Preview(showBackground = true, device = NEXUS_5) -@Composable -private fun PreviewShortScreen() { - AppThemeSurface { - Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { - Keyboard( - onClick = {}, - onClickBackspace = {}, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} 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 d83935d71..ec2659c94 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -9,13 +9,17 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.STUB_RATE +import to.bitkit.models.asBtc +import to.bitkit.models.formatCurrency import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.currencyViewModel import to.bitkit.ui.shared.util.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.CurrencyUiState +import java.math.BigDecimal @Composable fun MoneyDisplay( @@ -109,15 +113,22 @@ fun MoneyCaptionB( fun rememberMoneyText( sats: Long, reversed: Boolean = false, - currencies: CurrencyUiState = LocalCurrencies.current, + currencies: CurrencyState = LocalCurrencies.current, unit: PrimaryDisplay = if (reversed) currencies.primaryDisplay.not() else currencies.primaryDisplay, showSymbol: Boolean = unit == PrimaryDisplay.FIAT, ): String? { val isPreview = LocalInspectionMode.current if (isPreview) { + val symbol = if (unit == PrimaryDisplay.BITCOIN) BITCOIN_SYMBOL else "$" return buildString { - if (showSymbol) append("${currencies.primarySymbol()} ") - append(sats.formatToModernDisplay()) + if (showSymbol) append("$symbol ") + if (unit == PrimaryDisplay.BITCOIN) { + append(sats.formatToModernDisplay()) + } else { + // For fiat preview, convert sats to fiat using STUB_RATE and formatCurrency + val fiatValue = sats.asBtc().multiply(BigDecimal.valueOf(STUB_RATE)) + append(fiatValue.formatCurrency() ?: "0.00") + } } } diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt new file mode 100644 index 000000000..b18cbae27 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt @@ -0,0 +1,291 @@ +package to.bitkit.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.PrimaryDisplay +import to.bitkit.repositories.CurrencyState +import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.AmountInputViewModel +import to.bitkit.viewmodels.previewAmountInputViewModel + +const val KEY_DELETE = "delete" +const val KEY_000 = "000" +const val KEY_DECIMAL = "." +private val maxKeyboardHeight = 300.dp +private val idealButtonHeight = 75.dp +private val minButtonHeight = 50.dp +private const val ROWS = 4 +private const val COLUMNS = 3 +private const val ALPHA_PRESSED = 0.2f +private val pressHaptic = HapticFeedbackType.VirtualKey +private val errorHaptic = HapticFeedbackType.Reject + +/** + * Numeric keyboard. Can be used together with [NumberPadTextField] for amounts. + */ +@Composable +fun NumberPad( + viewModel: AmountInputViewModel, + modifier: Modifier = Modifier, + currencies: CurrencyState = LocalCurrencies.current, + type: NumberPadType = viewModel.getNumberPadType(currencies), + availableHeight: Dp? = null, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val errorKey: String? = uiState.errorKey + val onPress: (String) -> Unit = { key -> viewModel.handleNumberPadInput(key, currencies) } + + BoxWithConstraints(modifier = modifier) { + val constraintsHeight = this.maxHeight + val effectiveHeight = availableHeight ?: constraintsHeight + val idealTotalHeight = idealButtonHeight * ROWS + + val maxAllowedHeight = minOf(maxKeyboardHeight, effectiveHeight) + + val buttonHeight = when { + // If we have plenty of space, use ideal height + maxAllowedHeight >= idealTotalHeight -> idealButtonHeight + // If space is limited, calculate proportional height but ensure minimum + maxAllowedHeight >= (minButtonHeight * ROWS) -> maxAllowedHeight / ROWS + // If extremely limited, use absolute minimum + else -> minButtonHeight + } + + val totalKeyboardHeight = buttonHeight * ROWS + + LazyVerticalGrid( + columns = GridCells.Fixed(COLUMNS), + userScrollEnabled = false, + modifier = Modifier.height(totalKeyboardHeight), + ) { + items((1..9).map { "$it" }) { number -> + NumberPadKeyButton( + text = number, + onPress = onPress, + height = buttonHeight, + hasError = errorKey == number, + ) + } + item { + when (type) { + NumberPadType.SIMPLE -> Box( + modifier = Modifier + .height(buttonHeight) + .fillMaxWidth() + ) + + NumberPadType.INTEGER -> NumberPadKeyButton( + text = KEY_000, + onPress = onPress, + height = buttonHeight, + hasError = errorKey == KEY_000, + testTag = "N000", + ) + + NumberPadType.DECIMAL -> NumberPadKeyButton( + text = KEY_DECIMAL, + onPress = onPress, + height = buttonHeight, + hasError = errorKey == KEY_DECIMAL, + testTag = "NDecimal", + ) + } + } + item { + NumberPadKeyButton( + text = "0", + onPress = onPress, + height = buttonHeight, + hasError = errorKey == "0", + ) + } + item { + NumberPadDeleteButton( + onPress = { onPress(KEY_DELETE) }, + height = buttonHeight, + modifier = Modifier.testTag("NRemove"), + ) + } + } + } +} + +enum class NumberPadType { SIMPLE, INTEGER, DECIMAL } + +@Composable +fun NumberPadKeyButton( + text: String, + onPress: (String) -> Unit, + height: Dp, + modifier: Modifier = Modifier, + hasError: Boolean = false, + testTag: String = "N$text", +) { + NumberPadKey( + onClick = { onPress(text) }, + height = height, + haptic = if (hasError) errorHaptic else pressHaptic, + modifier = modifier.testTag(testTag), + ) { + Text( + text = text, + fontSize = when { + height < 60.dp -> 20.sp + height < 70.dp -> 22.sp + else -> 24.sp + }, + textAlign = TextAlign.Center, + color = if (hasError) Colors.Red else Colors.White, + ) + } +} + +@Composable +internal fun NumberPadDeleteButton( + onPress: () -> Unit, + height: Dp, + modifier: Modifier = Modifier, +) { + NumberPadKeyIcon( + icon = R.drawable.ic_backspace, + contentDescription = stringResource(R.string.common__delete), + onClick = onPress, + height = height, + modifier = modifier, + ) +} + +@Composable +fun NumberPadKeyIcon( + @DrawableRes icon: Int, + contentDescription: String?, + onClick: () -> Unit, + height: Dp, + modifier: Modifier = Modifier, +) { + NumberPadKey( + onClick = onClick, + height = height, + modifier = modifier, + ) { + Icon( + painter = painterResource(icon), + contentDescription = contentDescription, + ) + } +} + +@Composable +fun NumberPadKey( + onClick: () -> Unit, + height: Dp, + modifier: Modifier = Modifier, + haptic: HapticFeedbackType = pressHaptic, + content: @Composable (BoxScope.() -> Unit), +) { + val haptics = LocalHapticFeedback.current + Box( + content = content, + contentAlignment = Alignment.Center, + modifier = modifier + .height(height) + .fillMaxWidth() + .clickableAlpha(ALPHA_PRESSED) { + haptics.performHapticFeedback(haptic) + onClick() + }, + ) +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + ScreenColumn { + FillHeight() + NumberPad( + viewModel = previewAmountInputViewModel(), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewClassic() { + AppThemeSurface { + ScreenColumn { + FillHeight() + NumberPad( + viewModel = previewAmountInputViewModel(), + currencies = CurrencyState( + displayUnit = BitcoinDisplayUnit.CLASSIC, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewFiat() { + AppThemeSurface { + ScreenColumn { + FillHeight() + NumberPad( + viewModel = previewAmountInputViewModel(), + currencies = CurrencyState( + primaryDisplay = PrimaryDisplay.FIAT, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Preview(showSystemUi = true, device = NEXUS_5) +@Composable +private fun PreviewSmall() { + AppThemeSurface { + ScreenColumn { + FillHeight() + NumberPad( + viewModel = previewAmountInputViewModel(), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadSimple.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadSimple.kt index 4ac7bacf6..f4c8a7b32 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadSimple.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadSimple.kt @@ -10,14 +10,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable 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 to.bitkit.R import to.bitkit.ui.theme.AppThemeSurface -const val KEY_DELETE = "delete" - private val matrix = listOf( listOf("1", "2", "3"), listOf("4", "5", "6"), @@ -40,9 +36,10 @@ fun NumberPadSimple( .fillMaxWidth() ) { row.forEach { number -> - KeyTextButton( + NumberPadKeyButton( text = number, - onClick = onPress, + onPress = onPress, + height = 75.dp, modifier = Modifier.weight(1f) ) } @@ -56,16 +53,16 @@ fun NumberPadSimple( .fillMaxWidth() ) { Box(modifier = Modifier.weight(1f)) - KeyTextButton( + NumberPadKeyButton( text = "0", - onClick = onPress, + onPress = onPress, + height = 75.dp, modifier = Modifier.weight(1f) ) - KeyIconButton( - icon = R.drawable.ic_backspace, - contentDescription = stringResource(R.string.common__delete), - onClick = { onPress(KEY_DELETE) }, + NumberPadDeleteButton( + onPress = { onPress(KEY_DELETE) }, + height = 75.dp, modifier = Modifier .weight(1f) .testTag("NRemove") diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt index 99d64a84a..46d921247 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt @@ -2,228 +2,68 @@ 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.fillMaxWidth 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.saveable.rememberSaveable -import androidx.compose.runtime.setValue +import androidx.compose.runtime.State import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import to.bitkit.ext.removeSpaces -import to.bitkit.ext.toLongOrDefault +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.models.BITCOIN_SYMBOL -import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.PrimaryDisplay -import to.bitkit.models.SATS_IN_BTC -import to.bitkit.models.asBtc +import to.bitkit.models.USD_SYMBOL import to.bitkit.models.formatToModernDisplay -import to.bitkit.ui.currencyViewModel +import to.bitkit.repositories.CurrencyState +import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.shared.util.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.viewmodels.CurrencyViewModel -import java.math.BigDecimal +import to.bitkit.viewmodels.AmountInputUiState +import to.bitkit.viewmodels.AmountInputViewModel +/** + * Amount view to be used with [NumberPad] + */ @Composable fun NumberPadTextField( - input: String, - displayUnit: BitcoinDisplayUnit, - primaryDisplay: PrimaryDisplay, + viewModel: AmountInputViewModel, modifier: Modifier = Modifier, showSecondaryField: Boolean = true, + uiState: State = viewModel.uiState.collectAsStateWithLifecycle(), + currencies: CurrencyState = LocalCurrencies.current, + onClick: (() -> Unit)? = { viewModel.switchUnit(currencies) }, ) { - val isPreview = LocalInspectionMode.current - if (isPreview) { - return MoneyAmount( - modifier = modifier, - value = input.toLongOrNull()?.formatToModernDisplay() ?: input, - unit = primaryDisplay, - placeholder = "", - showPlaceholder = true, - showSecondaryField = showSecondaryField, - satoshis = 0, - currencySymbol = if (primaryDisplay == PrimaryDisplay.BITCOIN) BITCOIN_SYMBOL else "$" - ) - } - - 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().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, - showSecondaryField = showSecondaryField, + modifier = modifier.then(Modifier.clickableAlpha(onClick = onClick)), + value = uiState.value.displayText, + unit = currencies.primaryDisplay, + placeholder = viewModel.getPlaceholder(currencies), showPlaceholder = true, - satoshis = satoshis.toLongOrNull() ?: 0, - currencySymbol = currency.getCurrencySymbol() + satoshis = uiState.value.amountSats, + currencySymbol = currencies.currencySymbol, + showSecondaryField = showSecondaryField, ) } @Composable -fun AmountInputHandler( - input: String, - primaryDisplay: PrimaryDisplay, - displayUnit: BitcoinDisplayUnit, - onInputChanged: (String) -> Unit, - onAmountCalculated: (String) -> Unit, - currencyVM: CurrencyViewModel = hiltViewModel(), - overrideSats: Long? = null, -) { - var lastDisplay by rememberSaveable { mutableStateOf(primaryDisplay) } - - LaunchedEffect(overrideSats) { - overrideSats?.let { sats -> - val newInput = when (primaryDisplay) { - PrimaryDisplay.BITCOIN -> { - if (displayUnit == BitcoinDisplayUnit.MODERN) { - sats.toString() - } else { - sats.asBtc().toString() - } - } - - PrimaryDisplay.FIAT -> { - currencyVM.convert(sats)?.formatted ?: "0" - } - } - onInputChanged(newInput) - } - } - - LaunchedEffect(primaryDisplay) { - if (primaryDisplay == lastDisplay) return@LaunchedEffect - lastDisplay = primaryDisplay - val newInput = when (primaryDisplay) { - PrimaryDisplay.BITCOIN -> { // Convert fiat to sats - val amountLong = currencyVM.convertFiatToSats(input.replace(",", "").toDoubleOrNull() ?: 0.0) - if (amountLong > 0.0) amountLong.toString() else "" - } - - PrimaryDisplay.FIAT -> { // Convert sats to fiat - val convertedAmount = currencyVM.convert(input.toLongOrDefault()) - if (( - convertedAmount?.value - ?: BigDecimal(0) - ) > BigDecimal(0) - ) { - convertedAmount?.formatted.toString() - } else { - "" - } - } - } - onInputChanged(newInput) - } - - LaunchedEffect(input) { - val sats = when (primaryDisplay) { - PrimaryDisplay.BITCOIN -> { - if (displayUnit == BitcoinDisplayUnit.MODERN) { - input - } else { - (input.toLongOrDefault() * SATS_IN_BTC).toString() - } - } - - PrimaryDisplay.FIAT -> { - val convertedAmount = currencyVM.convertFiatToSats(input.replace(",", "").toDoubleOrNull() ?: 0.0) - convertedAmount.toString() - } - } - onAmountCalculated(sats) - } -} - -@Composable -fun MoneyAmount( - modifier: Modifier = Modifier, +private fun MoneyAmount( value: String, unit: PrimaryDisplay, placeholder: String, - showPlaceholder: Boolean, satoshis: Long, - currencySymbol: String, + modifier: Modifier = Modifier, + currencySymbol: String = BITCOIN_SYMBOL, + showPlaceholder: Boolean = true, showSecondaryField: Boolean = true, + valueStyle: SpanStyle = SpanStyle(color = Colors.White), + placeholderStyle: SpanStyle = SpanStyle(color = Colors.White50), ) { Column( modifier = modifier.semantics { contentDescription = value }, @@ -231,148 +71,131 @@ fun MoneyAmount( ) { if (showSecondaryField) { MoneySSB(sats = satoshis, unit = unit.not(), color = Colors.White64, showSymbol = true) - - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) } - Row( - verticalAlignment = Alignment.CenterVertically + 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, + text = buildAnnotatedString { + if (value != placeholder) { + withStyle(valueStyle) { + append(value) + } + } + if (placeholder.isNotEmpty() && showPlaceholder) { + withStyle(placeholderStyle) { + append(placeholder) + } + } + } ) } } } -@Preview(name = "FIAT - Empty", group = "MoneyAmount", showBackground = true) +@Preview() @Composable -fun PreviewMoneyAmountFiatEmpty() { +private fun PreviewFiatEmpty() { AppThemeSurface { MoneyAmount( value = "", unit = PrimaryDisplay.FIAT, placeholder = ".00", - showPlaceholder = true, satoshis = 0, - currencySymbol = "$" + currencySymbol = USD_SYMBOL, + modifier = Modifier.fillMaxWidth() ) } } -@Preview(name = "FIAT - With Value", group = "MoneyAmount", showBackground = true) +@Preview() @Composable -fun PreviewMoneyAmountFiatWithValue() { +private fun PreviewFiatPartial() { AppThemeSurface { MoneyAmount( - value = "125.50", + value = "125.", 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 = "₿" + placeholder = "00", + satoshis = 1_250_000, + currencySymbol = USD_SYMBOL, + modifier = Modifier.fillMaxWidth() ) } } -@Preview(name = "BITCOIN - Modern With Value", group = "MoneyAmount", showBackground = true) +@Preview() @Composable -fun PreviewMoneyAmountBitcoinModernWithValue() { +private fun PreviewFiatValue() { AppThemeSurface { MoneyAmount( - value = "1.25", - unit = PrimaryDisplay.BITCOIN, - placeholder = "00000", - showPlaceholder = true, - satoshis = 125000000, - currencySymbol = "₿" + value = "125.50", + unit = PrimaryDisplay.FIAT, + placeholder = "", + satoshis = 1_250_000, + currencySymbol = USD_SYMBOL, + modifier = Modifier.fillMaxWidth() ) } } -@Preview(name = "BITCOIN - Classic Empty", group = "MoneyAmount", showBackground = true) +@Preview() @Composable -fun PreviewMoneyAmountBitcoinClassicEmpty() { +private fun PreviewClassicEmpty() { AppThemeSurface { MoneyAmount( value = "", unit = PrimaryDisplay.BITCOIN, - placeholder = ".00000000", - showPlaceholder = true, + placeholder = "0.00000000", satoshis = 0, - currencySymbol = "₿" + modifier = Modifier.fillMaxWidth() ) } } -@Preview(name = "BITCOIN - Classic With Value", group = "MoneyAmount", showBackground = true) +@Preview() @Composable -fun PreviewMoneyAmountBitcoinClassicWithValue() { +private fun PreviewClassicValue() { AppThemeSurface { MoneyAmount( - value = "125000000", + value = "0.0025", unit = PrimaryDisplay.BITCOIN, - placeholder = "", - showPlaceholder = true, - satoshis = 125000000, - currencySymbol = "₿" + placeholder = "0000", + satoshis = 1_250_000, + modifier = Modifier.fillMaxWidth() ) } } -@Preview(name = "FIAT - Partial Input", group = "MoneyAmount", showBackground = true) +@Preview() @Composable -fun PreviewMoneyAmountFiatPartial() { +private fun PreviewModernEmpty() { AppThemeSurface { MoneyAmount( - value = "125.", - unit = PrimaryDisplay.FIAT, - placeholder = "00", - showPlaceholder = true, - satoshis = 12500000000, - currencySymbol = "$" + value = "", + unit = PrimaryDisplay.BITCOIN, + placeholder = "0", + satoshis = 0, + modifier = Modifier.fillMaxWidth() ) } } -@Preview(name = "BITCOIN - Partial Input", group = "MoneyAmount", showBackground = true) +@Preview() @Composable -fun PreviewMoneyAmountBitcoinPartial() { +private fun PreviewModernValue() { AppThemeSurface { MoneyAmount( - value = "1.25", + value = 1_250_000L.formatToModernDisplay(), unit = PrimaryDisplay.BITCOIN, - placeholder = "00000", - showPlaceholder = true, - satoshis = 125000000, - currencySymbol = "₿" + placeholder = "", + satoshis = 1_250_000, + modifier = Modifier.fillMaxWidth() ) } } diff --git a/app/src/main/java/to/bitkit/ui/components/UnitButton.kt b/app/src/main/java/to/bitkit/ui/components/UnitButton.kt index 185d37b6b..0c801eb1a 100644 --- a/app/src/main/java/to/bitkit/ui/components/UnitButton.kt +++ b/app/src/main/java/to/bitkit/ui/components/UnitButton.kt @@ -10,20 +10,20 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.models.PrimaryDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.currencyViewModel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.viewmodels.CurrencyUiState import to.bitkit.viewmodels.CurrencyViewModel @Composable fun UnitButton( modifier: Modifier = Modifier, color: Color = Colors.Brand, - currencies: CurrencyUiState = LocalCurrencies.current, - currency: CurrencyViewModel? = currencyViewModel, - onClick: () -> Unit = { currency?.togglePrimaryDisplay() }, + currencies: CurrencyState = LocalCurrencies.current, + currencyVM: CurrencyViewModel? = currencyViewModel, + onClick: () -> Unit = { currencyVM?.switchUnit() }, ) { NumberPadActionButton( text = if (currencies.primaryDisplay == PrimaryDisplay.BITCOIN) "Bitcoin" else currencies.selectedCurrency, @@ -42,8 +42,8 @@ private fun Preview() { verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp) ) { - UnitButton(currencies = CurrencyUiState(primaryDisplay = PrimaryDisplay.BITCOIN)) - UnitButton(currencies = CurrencyUiState(primaryDisplay = PrimaryDisplay.FIAT)) + UnitButton(currencies = CurrencyState(primaryDisplay = PrimaryDisplay.BITCOIN)) + UnitButton(currencies = CurrencyState(primaryDisplay = PrimaryDisplay.FIAT)) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt index bee3620cd..36ca25661 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt @@ -85,7 +85,7 @@ fun SavingsAdvancedScreen( SavingsAdvancedContent( channelItems = channelItems, onChannelItemClick = { channelId -> toggleChannel(channelId) }, - onAmountClick = { currency.togglePrimaryDisplay() }, + onAmountClick = { currency.switchUnit() }, onContinueClick = { transfer.setSelectedChannelIds( selectedChannelIds.takeUnless { it.size == openChannels.size } ?: emptySet() diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index ffd0f07ed..5d3814ece 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -77,7 +77,7 @@ fun SavingsConfirmScreen( hasSelected = hasSelected, onBackClick = onBackClick, onCloseClick = onCloseClick, - onAmountClick = { currency.togglePrimaryDisplay() }, + onAmountClick = { currency.switchUnit() }, onAdvancedClick = onAdvancedClick, onSelectAllClick = { transfer.setSelectedChannelIds(emptySet()) }, onConfirm = { diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index 0da240830..7552bf002 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -15,22 +15,22 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import okhttp3.internal.toLongOrDefault import to.bitkit.R -import to.bitkit.models.PrimaryDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.components.AmountInputHandler import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth -import to.bitkit.ui.components.Keyboard import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton @@ -44,11 +44,14 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.CurrencyUiState +import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.TransferEffect import to.bitkit.viewmodels.TransferToSpendingUiState import to.bitkit.viewmodels.TransferViewModel +import to.bitkit.viewmodels.previewAmountInputViewModel +import kotlin.math.min +@Suppress("ViewModelForwarding") @Composable fun SpendingAmountScreen( viewModel: TransferViewModel, @@ -57,10 +60,13 @@ fun SpendingAmountScreen( onOrderCreated: () -> Unit = {}, toastException: (Throwable) -> Unit, toast: (title: String, description: String) -> Unit, + currencies: CurrencyState = LocalCurrencies.current, + amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { - val currencies = LocalCurrencies.current val uiState by viewModel.spendingUiState.collectAsStateWithLifecycle() val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle() + val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current LaunchedEffect(Unit) { viewModel.updateLimits() @@ -76,41 +82,48 @@ fun SpendingAmountScreen( } } - AmountInputHandler( - input = uiState.input, - overrideSats = uiState.overrideSats, - primaryDisplay = currencies.primaryDisplay, - displayUnit = currencies.displayUnit, - onInputChanged = viewModel::onInputChanged, - onAmountCalculated = { sats -> - viewModel.handleCalculatedAmount(sats.toLongOrDefault(0)) - }, - ) - Content( isNodeRunning = isNodeRunning, uiState = uiState, + amountInputViewModel = amountInputViewModel, currencies = currencies, onBackClick = onBackClick, onCloseClick = onCloseClick, - onClickQuarter = viewModel::onClickQuarter, - onClickMaxAmount = viewModel::onClickMaxAmount, - onConfirmAmount = viewModel::onConfirmAmount, - onInputChange = viewModel::onInputChanged, + onClickQuarter = { + val quarter = uiState.balanceAfterFeeQuarter() + val max = uiState.maxAllowedToSend + if (quarter > max) { + toast( + context.getString(R.string.lightning__spending_amount__error_max__title), + context.getString(R.string.lightning__spending_amount__error_max__description) + .replace("{amount}", "$max"), + ) + } + val quarterAmount = min(quarter, max) + viewModel.updateLimits(quarterAmount) + amountInputViewModel.setSats(quarterAmount, currencies) + }, + onClickMaxAmount = { + val newAmountSats = uiState.maxAllowedToSend + viewModel.updateLimits(newAmountSats) + amountInputViewModel.setSats(newAmountSats, currencies) + }, + onConfirmAmount = { viewModel.onConfirmAmount(amountUiState.amountSats) }, ) } +@Suppress("ViewModelForwarding") @Composable private fun Content( isNodeRunning: Boolean, uiState: TransferToSpendingUiState, - currencies: CurrencyUiState, + amountInputViewModel: AmountInputViewModel, onBackClick: () -> Unit, onCloseClick: () -> Unit, onClickQuarter: () -> Unit, onClickMaxAmount: () -> Unit, onConfirmAmount: () -> Unit, - onInputChange: (String) -> Unit, + currencies: CurrencyState = LocalCurrencies.current, ) { ScreenColumn { AppTopBar( @@ -122,11 +135,11 @@ private fun Content( if (isNodeRunning) { SpendingAmountNodeRunning( uiState = uiState, + amountInputViewModel = amountInputViewModel, currencies = currencies, onClickQuarter = onClickQuarter, onClickMaxAmount = onClickMaxAmount, onConfirmAmount = onConfirmAmount, - onInputChange = onInputChange, ) } else { SyncNodeView( @@ -138,14 +151,15 @@ private fun Content( } } +@Suppress("ViewModelForwarding") @Composable private fun SpendingAmountNodeRunning( uiState: TransferToSpendingUiState, - currencies: CurrencyUiState, + amountInputViewModel: AmountInputViewModel, + currencies: CurrencyState, onClickQuarter: () -> Unit, onClickMaxAmount: () -> Unit, onConfirmAmount: () -> Unit, - onInputChange: (String) -> Unit, ) { Column( modifier = Modifier @@ -154,6 +168,8 @@ private fun SpendingAmountNodeRunning( .imePadding() .testTag("SpendingAmount") ) { + val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + VerticalSpacer(minHeight = 16.dp, maxHeight = 32.dp) Display( @@ -164,10 +180,9 @@ private fun SpendingAmountNodeRunning( FillHeight() NumberPadTextField( - input = uiState.input, - displayUnit = currencies.displayUnit, + viewModel = amountInputViewModel, + currencies = currencies, showSecondaryField = false, - primaryDisplay = currencies.primaryDisplay, modifier = Modifier .fillMaxWidth() .testTag("SpendingAmountNumberField") @@ -192,15 +207,17 @@ private fun SpendingAmountNodeRunning( MoneySSB(sats = uiState.balanceAfterFee, modifier = Modifier.testTag("SpendingAmountUnit")) } FillWidth() - UnitButton(color = Colors.Purple) - // 25% Button + UnitButton( + color = Colors.Purple, + onClick = { amountInputViewModel.switchUnit(currencies) }, + modifier = Modifier.testTag("SpendingNumberPadUnit") + ) NumberPadActionButton( text = stringResource(R.string.lightning__spending_amount__quarter), color = Colors.Purple, onClick = onClickQuarter, modifier = Modifier.testTag("SpendingAmountQuarter") ) - // Max Button NumberPadActionButton( text = stringResource(R.string.common__max), color = Colors.Purple, @@ -211,17 +228,9 @@ private fun SpendingAmountNodeRunning( HorizontalDivider() VerticalSpacer(16.dp) - - Keyboard( - onClick = { number -> - onInputChange(if (uiState.input == "0") number else uiState.input + number) - }, - onClickBackspace = { - onInputChange(if (uiState.input.length > 1) uiState.input.dropLast(1) else "0") - }, - isDecimal = currencies.primaryDisplay == PrimaryDisplay.FIAT, - modifier = Modifier - .fillMaxWidth() + NumberPad( + viewModel = amountInputViewModel, + modifier = Modifier.fillMaxWidth() ) VerticalSpacer(8.dp) @@ -229,7 +238,7 @@ private fun SpendingAmountNodeRunning( PrimaryButton( text = stringResource(R.string.common__continue), onClick = onConfirmAmount, - enabled = uiState.satsAmount != 0L && uiState.satsAmount <= uiState.maxAllowedToSend, + enabled = amountUiState.amountSats != 0L && amountUiState.amountSats <= uiState.maxAllowedToSend, isLoading = uiState.isLoading, modifier = Modifier.testTag("SpendingAmountContinue") ) @@ -244,50 +253,50 @@ private fun Preview() { AppThemeSurface { Content( isNodeRunning = true, - uiState = TransferToSpendingUiState(input = "5 000"), - currencies = CurrencyUiState(), + uiState = TransferToSpendingUiState(), + amountInputViewModel = previewAmountInputViewModel(), + currencies = CurrencyState(), onBackClick = {}, onCloseClick = {}, onClickQuarter = {}, onClickMaxAmount = {}, onConfirmAmount = {}, - onInputChange = {}, ) } } @Preview(showBackground = true, device = NEXUS_5) @Composable -private fun Preview2() { +private fun PreviewSmall() { AppThemeSurface { Content( isNodeRunning = true, - uiState = TransferToSpendingUiState(input = "5 000"), - currencies = CurrencyUiState(), + uiState = TransferToSpendingUiState(), + amountInputViewModel = previewAmountInputViewModel(), + currencies = CurrencyState(), onBackClick = {}, onCloseClick = {}, onClickQuarter = {}, onClickMaxAmount = {}, onConfirmAmount = {}, - onInputChange = {}, ) } } @Preview(showBackground = true, device = NEXUS_5) @Composable -private fun Preview3() { +private fun PreviewInitializing() { AppThemeSurface { Content( isNodeRunning = false, - uiState = TransferToSpendingUiState(input = "5 000"), - currencies = CurrencyUiState(), + uiState = TransferToSpendingUiState(), + amountInputViewModel = previewAmountInputViewModel(), + currencies = CurrencyState(), onBackClick = {}, onCloseClick = {}, onClickQuarter = {}, onClickMaxAmount = {}, onConfirmAmount = {}, - onInputChange = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 3aa382112..3c66058f0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,123 +39,108 @@ import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R -import to.bitkit.models.BitcoinDisplayUnit -import to.bitkit.models.PrimaryDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.repositories.WalletState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.blocktankViewModel -import to.bitkit.ui.components.AmountInputHandler import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.Keyboard +import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.UnitButton import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.util.clickableAlpha import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.keyboardAsState import to.bitkit.utils.Logger -import to.bitkit.viewmodels.CurrencyUiState +import to.bitkit.viewmodels.AmountInputViewModel +import to.bitkit.viewmodels.previewAmountInputViewModel +@Suppress("ViewModelForwarding") @Composable fun EditInvoiceScreen( - currencyUiState: CurrencyUiState = LocalCurrencies.current, - editInvoiceVM: EditInvoiceVM = hiltViewModel(), + amountInputViewModel: AmountInputViewModel, walletUiState: WalletState, updateInvoice: (ULong?) -> Unit, onClickAddTag: () -> Unit, onClickTag: (String) -> Unit, - onInputUpdated: (String) -> Unit, onDescriptionUpdate: (String) -> Unit, onBack: () -> Unit, navigateReceiveConfirm: (CjitEntryDetails) -> Unit, + currencies: CurrencyState = LocalCurrencies.current, + editInvoiceVM: EditInvoiceVM = hiltViewModel(), ) { - val currencyVM = currencyViewModel ?: return val blocktankVM = blocktankViewModel ?: return - var satsString by rememberSaveable { mutableStateOf("") } var keyboardVisible by remember { mutableStateOf(false) } var isSoftKeyboardVisible by keyboardAsState() + val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { editInvoiceVM.editInvoiceEffect.collect { effect -> + val receiveSats = amountInputUiState.amountSats.toULong() when (effect) { is EditInvoiceVM.EditInvoiceScreenEffects.NavigateAddLiquidity -> { - val receiveSats = satsString.toULongOrNull() updateInvoice(receiveSats) - if (receiveSats == null) { + if (receiveSats == 0UL) { onBack() return@collect } - satsString.toULongOrNull()?.let { sats -> - runCatching { blocktankVM.createCjit(sats) }.onSuccess { entry -> - navigateReceiveConfirm( - CjitEntryDetails( - networkFeeSat = entry.networkFeeSat.toLong(), - serviceFeeSat = entry.serviceFeeSat.toLong(), - channelSizeSat = entry.channelSizeSat.toLong(), - feeSat = entry.feeSat.toLong(), - receiveAmountSats = receiveSats.toLong(), - invoice = entry.invoice.request, - ) + runCatching { blocktankVM.createCjit(receiveSats) }.onSuccess { entry -> + navigateReceiveConfirm( + CjitEntryDetails( + networkFeeSat = entry.networkFeeSat.toLong(), + serviceFeeSat = entry.serviceFeeSat.toLong(), + channelSizeSat = entry.channelSizeSat.toLong(), + feeSat = entry.feeSat.toLong(), + receiveAmountSats = receiveSats.toLong(), + invoice = entry.invoice.request, ) - }.onFailure { e -> - Logger.error(e = e, msg = "error creating cjit invoice", context = "EditInvoiceScreen") - onBack() - } + ) + }.onFailure { e -> + Logger.error("error creating cjit invoice", e, context = "EditInvoiceScreen") + onBack() } } EditInvoiceVM.EditInvoiceScreenEffects.UpdateInvoice -> { - updateInvoice(satsString.toULongOrNull()) + updateInvoice(receiveSats) onBack() } } } } - AmountInputHandler( - input = walletUiState.balanceInput, - primaryDisplay = currencyUiState.primaryDisplay, - displayUnit = currencyUiState.displayUnit, - onInputChanged = onInputUpdated, - onAmountCalculated = { sats -> satsString = sats }, - currencyVM = currencyVM - ) - EditInvoiceContent( - input = walletUiState.balanceInput, + amountInputViewModel = amountInputViewModel, noteText = walletUiState.bip21Description, - primaryDisplay = currencyUiState.primaryDisplay, - displayUnit = currencyUiState.displayUnit, + currencies = currencies, tags = walletUiState.selectedTags, onBack = onBack, onTextChanged = onDescriptionUpdate, keyboardVisible = keyboardVisible, onClickBalance = { if (keyboardVisible) { - currencyVM.togglePrimaryDisplay() + amountInputViewModel.switchUnit(currencies) } else { keyboardVisible = true } }, - onInputChanged = onInputUpdated, onContinueKeyboard = { keyboardVisible = false }, onContinueGeneral = { - updateInvoice(satsString.toULongOrNull()) + updateInvoice(amountInputUiState.amountSats.toULong()) editInvoiceVM.onClickContinue() }, onClickAddTag = onClickAddTag, @@ -165,14 +149,13 @@ fun EditInvoiceScreen( ) } +@Suppress("ViewModelForwarding") @Composable fun EditInvoiceContent( - input: String, + amountInputViewModel: AmountInputViewModel, noteText: String, isSoftKeyboardVisible: Boolean, keyboardVisible: Boolean, - primaryDisplay: PrimaryDisplay, - displayUnit: BitcoinDisplayUnit, tags: List, onBack: () -> Unit, onContinueKeyboard: () -> Unit, @@ -181,8 +164,8 @@ fun EditInvoiceContent( onClickAddTag: () -> Unit, onTextChanged: (String) -> Unit, onClickTag: (String) -> Unit, - onInputChanged: (String) -> Unit, modifier: Modifier = Modifier, + currencies: CurrencyState = LocalCurrencies.current, ) { BoxWithConstraints( modifier = modifier @@ -229,12 +212,10 @@ fun EditInvoiceContent( VerticalSpacer(16.dp) NumberPadTextField( - input = input, - displayUnit = displayUnit, - primaryDisplay = primaryDisplay, + viewModel = amountInputViewModel, + onClick = onClickBalance, modifier = Modifier .fillMaxWidth() - .clickableAlpha(onClick = onClickBalance) .testTag("ReceiveNumberPadTextField") ) @@ -243,11 +224,11 @@ fun EditInvoiceContent( visible = keyboardVisible, enter = slideInVertically( initialOffsetY = { fullHeight -> fullHeight }, - animationSpec = tween(durationMillis = 300) + animationSpec = tween() ) + fadeIn(), exit = slideOutVertically( targetOffsetY = { fullHeight -> fullHeight }, - animationSpec = tween(durationMillis = 300) + animationSpec = tween() ) + fadeOut() ) { Column( @@ -261,6 +242,7 @@ fun EditInvoiceContent( modifier = Modifier.fillMaxWidth() ) { UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, modifier = Modifier .height(28.dp) .testTag("ReceiveNumberPadUnit") @@ -269,18 +251,13 @@ fun EditInvoiceContent( HorizontalDivider(modifier = Modifier.padding(top = 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 = primaryDisplay == PrimaryDisplay.FIAT, + NumberPad( + viewModel = amountInputViewModel, + currencies = currencies, availableHeight = maxHeight, modifier = Modifier .fillMaxWidth() - .testTag("amount_keyboard") + .testTag("ReceiveNumberField") ) PrimaryButton( @@ -296,8 +273,8 @@ fun EditInvoiceContent( // Animated visibility for note section AnimatedVisibility( visible = !keyboardVisible, - enter = fadeIn(animationSpec = tween(durationMillis = 300)), - exit = fadeOut(animationSpec = tween(durationMillis = 300)) + enter = fadeIn(animationSpec = tween()), + exit = fadeOut(animationSpec = tween()) ) { Column { VerticalSpacer(44.dp) @@ -380,15 +357,12 @@ private fun Preview() { AppThemeSurface { BottomSheetPreview { EditInvoiceContent( - input = "123", + amountInputViewModel = previewAmountInputViewModel(), noteText = "", - primaryDisplay = PrimaryDisplay.BITCOIN, - displayUnit = BitcoinDisplayUnit.MODERN, onBack = {}, onTextChanged = {}, keyboardVisible = false, onClickBalance = {}, - onInputChanged = {}, onContinueGeneral = {}, onContinueKeyboard = {}, tags = listOf(), @@ -407,15 +381,12 @@ private fun PreviewWithTags() { AppThemeSurface { BottomSheetPreview { EditInvoiceContent( - input = "123", + amountInputViewModel = previewAmountInputViewModel(), noteText = "Note text", - primaryDisplay = PrimaryDisplay.BITCOIN, - displayUnit = BitcoinDisplayUnit.MODERN, onBack = {}, onTextChanged = {}, keyboardVisible = false, onClickBalance = {}, - onInputChanged = {}, onContinueGeneral = {}, onContinueKeyboard = {}, tags = listOf("Team", "Dinner", "Home", "Work"), @@ -434,15 +405,12 @@ private fun PreviewWithKeyboard() { AppThemeSurface { BottomSheetPreview { EditInvoiceContent( - input = "123", + amountInputViewModel = previewAmountInputViewModel(), noteText = "Note text", - primaryDisplay = PrimaryDisplay.BITCOIN, - displayUnit = BitcoinDisplayUnit.MODERN, onBack = {}, onTextChanged = {}, keyboardVisible = true, onClickBalance = {}, - onInputChanged = {}, onContinueGeneral = {}, onContinueKeyboard = {}, tags = listOf("Team", "Dinner", "Home"), @@ -461,15 +429,12 @@ private fun PreviewSmallScreen() { AppThemeSurface { BottomSheetPreview { EditInvoiceContent( - input = "123", + amountInputViewModel = previewAmountInputViewModel(), noteText = "Note text", - primaryDisplay = PrimaryDisplay.BITCOIN, - displayUnit = BitcoinDisplayUnit.MODERN, onBack = {}, onTextChanged = {}, keyboardVisible = true, onClickBalance = {}, - onInputChanged = {}, onContinueGeneral = {}, onContinueKeyboard = {}, tags = listOf("Team", "Dinner", "Home"), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt index e355a6df2..ab27e300d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -25,26 +24,25 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.PrimaryDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.appViewModel import to.bitkit.ui.blocktankViewModel -import to.bitkit.ui.components.AmountInputHandler import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth -import to.bitkit.ui.components.Keyboard import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.UnitButton import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.clickableAlpha @@ -53,23 +51,23 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.walletViewModel import to.bitkit.utils.Logger -import to.bitkit.viewmodels.CurrencyUiState +import to.bitkit.viewmodels.AmountInputViewModel +import to.bitkit.viewmodels.previewAmountInputViewModel +@Suppress("ViewModelForwarding") @Composable fun ReceiveAmountScreen( onCjitCreated: (CjitEntryDetails) -> Unit, onBack: () -> Unit, + currencies: CurrencyState = LocalCurrencies.current, + amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { val app = appViewModel ?: return val wallet = walletViewModel ?: return val blocktank = blocktankViewModel ?: return val walletState by wallet.uiState.collectAsStateWithLifecycle() - val currencyVM = currencyViewModel ?: return - val currencies = LocalCurrencies.current + val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - var input: String by remember { mutableStateOf("0") } - var overrideSats: Long? by remember { mutableStateOf(null) } - var satsAmount by remember { mutableLongStateOf(0L) } var isCreatingInvoice by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -77,49 +75,33 @@ fun ReceiveAmountScreen( blocktank.refreshMinCjitSats() } - AmountInputHandler( - input = input, - overrideSats = overrideSats, - primaryDisplay = currencies.primaryDisplay, - displayUnit = currencies.displayUnit, - onInputChanged = { newInput -> input = newInput }, - onAmountCalculated = { sats -> - satsAmount = sats.toLongOrNull() ?: 0L - overrideSats = null - }, - currencyVM = currencyVM, - ) - val minCjitSats by blocktank.minCjitSats.collectAsStateWithLifecycle() ReceiveAmountContent( - input = input, - satsAmount = satsAmount, + amountInputViewModel = amountInputViewModel, minCjitSats = minCjitSats, - currencyUiState = currencies, + currencies = currencies, isCreatingInvoice = isCreatingInvoice, - onInputChange = { input = it }, - onClickMin = { overrideSats = it }, - onClickAmount = { currencyVM.togglePrimaryDisplay() }, + canContinue = amountInputUiState.amountSats >= (minCjitSats?.toLong() ?: 0), onBack = onBack, + onClickMin = { amountInputViewModel.setSats(it, currencies) }, onContinue = { - val sats = satsAmount.toULong() + val sats = amountInputUiState.amountSats scope.launch { isCreatingInvoice = true - runCatching { require(walletState.nodeLifecycleState == NodeLifecycleState.Running) { "Should not be able to land on this screen if the node is not running." } - val entry = blocktank.createCjit(amountSats = sats) + val entry = blocktank.createCjit(amountSats = sats.toULong()) onCjitCreated( CjitEntryDetails( networkFeeSat = entry.networkFeeSat.toLong(), serviceFeeSat = entry.serviceFeeSat.toLong(), channelSizeSat = entry.channelSizeSat.toLong(), feeSat = entry.feeSat.toLong(), - receiveAmountSats = satsAmount, + receiveAmountSats = sats, invoice = entry.invoice.request, ) ) @@ -133,17 +115,16 @@ fun ReceiveAmountScreen( ) } +@Suppress("ViewModelForwarding") @Composable private fun ReceiveAmountContent( - input: String, - satsAmount: Long, + amountInputViewModel: AmountInputViewModel, minCjitSats: Int?, - currencyUiState: CurrencyUiState, isCreatingInvoice: Boolean, + canContinue: Boolean, modifier: Modifier = Modifier, - onInputChange: (String) -> Unit = {}, + currencies: CurrencyState = LocalCurrencies.current, onClickMin: (Long) -> Unit = {}, - onClickAmount: () -> Unit = {}, onBack: () -> Unit = {}, onContinue: () -> Unit = {}, ) { @@ -167,12 +148,9 @@ private fun ReceiveAmountContent( ) { VerticalSpacer(16.dp) NumberPadTextField( - input = input, - displayUnit = currencyUiState.displayUnit, - primaryDisplay = currencyUiState.primaryDisplay, + viewModel = amountInputViewModel, modifier = Modifier .fillMaxWidth() - .clickableAlpha(onClick = onClickAmount) .testTag("ReceiveNumberPadTextField") ) @@ -199,20 +177,17 @@ private fun ReceiveAmountContent( } ?: CircularProgressIndicator(modifier = Modifier.size(18.dp)) FillWidth() - UnitButton(modifier = Modifier.testTag("ReceiveNumberPadUnit")) + UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, + modifier = Modifier.testTag("ReceiveNumberPadUnit") + ) } VerticalSpacer(16.dp) HorizontalDivider() - Keyboard( - onClick = { number -> - onInputChange(if (input == "0") number else input + number) - }, - onClickBackspace = { - onInputChange(if (input.length > 1) input.dropLast(1) else "0") - }, - isDecimal = currencyUiState.primaryDisplay == PrimaryDisplay.FIAT, + NumberPad( + viewModel = amountInputViewModel, availableHeight = maxHeight, modifier = Modifier .fillMaxWidth() @@ -221,7 +196,7 @@ private fun ReceiveAmountContent( PrimaryButton( text = stringResource(R.string.common__continue), - enabled = !isCreatingInvoice && satsAmount != 0L, + enabled = !isCreatingInvoice && canContinue, isLoading = isCreatingInvoice, onClick = onContinue, modifier = Modifier.testTag("ContinueAmount") @@ -239,10 +214,9 @@ private fun Preview() { AppThemeSurface { BottomSheetPreview { ReceiveAmountContent( - input = "100", - satsAmount = 10000L, + amountInputViewModel = previewAmountInputViewModel(), + canContinue = true, minCjitSats = 5000, - currencyUiState = CurrencyUiState(), isCreatingInvoice = false, modifier = Modifier.sheetHeight(), ) @@ -256,10 +230,9 @@ private fun PreviewSmallScreen() { AppThemeSurface { BottomSheetPreview { ReceiveAmountContent( - input = "100", - satsAmount = 10000L, + amountInputViewModel = previewAmountInputViewModel(sats = 200), + canContinue = true, minCjitSats = 5000, - currencyUiState = CurrencyUiState(), isCreatingInvoice = false, modifier = Modifier.sheetHeight(), ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index db8e98188..13f7f0c97 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController @@ -22,6 +23,7 @@ import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.walletViewModel +import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.WalletViewModelEffects @@ -29,11 +31,13 @@ import to.bitkit.viewmodels.WalletViewModelEffects fun ReceiveSheet( navigateToExternalConnection: () -> Unit, walletState: MainUiState, + editInvoiceAmountViewModel: AmountInputViewModel = hiltViewModel(), ) { val wallet = requireNotNull(walletViewModel) val blocktank = requireNotNull(blocktankViewModel) val navController = rememberNavController() + LaunchedEffect(Unit) { editInvoiceAmountViewModel.clearInput() } val cjitInvoice = remember { mutableStateOf(null) } val showCreateCjit = remember { mutableStateOf(false) } @@ -164,7 +168,9 @@ fun ReceiveSheet( } composableWithDefaultTransitions { val walletUiState by wallet.walletState.collectAsStateWithLifecycle() + @Suppress("ViewModelForwarding") EditInvoiceScreen( + amountInputViewModel = editInvoiceAmountViewModel, walletUiState = walletUiState, onBack = { navController.popBackStack() }, updateInvoice = { sats -> @@ -179,9 +185,6 @@ fun ReceiveSheet( onDescriptionUpdate = { newText -> wallet.updateBip21Description(newText = newText) }, - onInputUpdated = { newText -> - wallet.updateBalanceInput(newText) - }, navigateReceiveConfirm = { entry -> cjitEntryDetails.value = entry navController.navigate(ReceiveRoute.ConfirmIncreaseInbound) 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 1bb53ae38..9a143e903 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,10 +10,10 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding 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.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -22,6 +22,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.LnurlWithdrawData import to.bitkit.R @@ -30,18 +32,17 @@ import to.bitkit.ext.maxWithdrawableSat import to.bitkit.models.BalanceState import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.PrimaryDisplay import to.bitkit.models.Toast +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalBalances import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.appViewModel -import to.bitkit.ui.components.AmountInputHandler import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth import to.bitkit.ui.components.HorizontalSpacer -import to.bitkit.ui.components.Keyboard import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton @@ -49,59 +50,56 @@ import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.UnitButton import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.clickableAlpha 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.AmountInputUiState +import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.LnurlParams import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState +import to.bitkit.viewmodels.previewAmountInputViewModel +@Suppress("ViewModelForwarding") @Composable fun SendAmountScreen( uiState: SendUiState, walletUiState: MainUiState, canGoBack: Boolean, - currencyUiState: CurrencyUiState = LocalCurrencies.current, onBack: () -> Unit, onEvent: (SendEvent) -> Unit, + currencies: CurrencyState = LocalCurrencies.current, + amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { - val currencyVM = currencyViewModel ?: return val app = appViewModel val context = LocalContext.current - var input: String by remember { mutableStateOf(uiState.amountInput) } - var overrideSats: Long? by remember { mutableStateOf(null) } + val amountInputUiState: AmountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + val currentOnEvent by rememberUpdatedState(onEvent) - AmountInputHandler( - input = input, - overrideSats = overrideSats, - primaryDisplay = currencyUiState.primaryDisplay, - displayUnit = currencyUiState.displayUnit, - onInputChanged = { newInput -> input = newInput }, - onAmountCalculated = { sats -> - onEvent(SendEvent.AmountChange(value = sats)) - overrideSats = null - }, - currencyVM = currencyVM, - ) + LaunchedEffect(Unit) { + if (uiState.amount > 0u) { + amountInputViewModel.setSats(uiState.amount.toLong(), currencies) + } + } + + LaunchedEffect(amountInputUiState.amountSats) { + currentOnEvent(SendEvent.AmountChange(amountInputUiState.amountSats.toULong())) + } SendAmountContent( - input = input, - uiState = uiState, walletUiState = walletUiState, - currencyUiState = currencyUiState, - primaryDisplay = currencyUiState.primaryDisplay, - displayUnit = currencyUiState.displayUnit, - onInputChanged = { input = it }, - onEvent = onEvent, - canGoBack = canGoBack, - onBack = onBack, + uiState = uiState, + amountInputViewModel = amountInputViewModel, + currencies = currencies, + onBack = { + onEvent(SendEvent.AmountReset) + onBack() + }.takeIf { canGoBack }, onClickMax = { maxSats -> // TODO port the RN sendMax logic if still needed if (uiState.payMethod == SendMethod.LIGHTNING && uiState.lnurl == null) { @@ -111,28 +109,26 @@ fun SendAmountScreen( description = context.getString(R.string.wallet__send_max_spending__description) ) } - overrideSats = maxSats + amountInputViewModel.setSats(maxSats, currencies) }, - onClickAmount = { currencyVM.togglePrimaryDisplay() }, + onClickPayMethod = { onEvent(SendEvent.PaymentMethodSwitch) }, + onContinue = { onEvent(SendEvent.AmountContinue) }, ) } +@Suppress("ViewModelForwarding") @Composable fun SendAmountContent( - input: String, walletUiState: MainUiState, uiState: SendUiState, + amountInputViewModel: AmountInputViewModel, modifier: Modifier = Modifier, balances: BalanceState = LocalBalances.current, - primaryDisplay: PrimaryDisplay, - displayUnit: BitcoinDisplayUnit, - currencyUiState: CurrencyUiState, - onInputChanged: (String) -> Unit, - onEvent: (SendEvent) -> Unit, - canGoBack: Boolean = true, - onBack: () -> Unit, + currencies: CurrencyState = LocalCurrencies.current, + onBack: (() -> Unit)? = {}, onClickMax: (Long) -> Unit = {}, - onClickAmount: () -> Unit = {}, + onClickPayMethod: () -> Unit = {}, + onContinue: () -> Unit = {}, ) { Column( modifier = modifier @@ -149,25 +145,19 @@ fun SendAmountContent( SheetTopBar( titleText = stringResource(titleRes), - onBack = { - onEvent(SendEvent.AmountReset) - onBack() - }.takeIf { canGoBack }, + onBack = onBack, ) when (walletUiState.nodeLifecycleState) { is NodeLifecycleState.Running -> { SendAmountNodeRunning( - input = input, + amountInputViewModel = amountInputViewModel, uiState = uiState, - currencyUiState = currencyUiState, - onInputChanged = onInputChanged, balances = balances, - displayUnit = displayUnit, - primaryDisplay = primaryDisplay, - onEvent = onEvent, + currencies = currencies, + onClickPayMethod = onClickPayMethod, onClickMax = onClickMax, - onClickAmount = onClickAmount, + onContinue = onContinue, ) } @@ -183,18 +173,16 @@ fun SendAmountContent( } } +@Suppress("ViewModelForwarding") @Composable private fun SendAmountNodeRunning( - input: String, + amountInputViewModel: AmountInputViewModel, uiState: SendUiState, balances: BalanceState, - primaryDisplay: PrimaryDisplay, - displayUnit: BitcoinDisplayUnit, - currencyUiState: CurrencyUiState, - onInputChanged: (String) -> Unit, - onEvent: (SendEvent) -> Unit, + currencies: CurrencyState, + onClickPayMethod: () -> Unit, onClickMax: (Long) -> Unit, - onClickAmount: () -> Unit, + onContinue: () -> Unit, ) { BoxWithConstraints { val maxHeight = this.maxHeight @@ -212,12 +200,9 @@ private fun SendAmountNodeRunning( VerticalSpacer(16.dp) NumberPadTextField( - input = input, - displayUnit = displayUnit, - primaryDisplay = primaryDisplay, + viewModel = amountInputViewModel, modifier = Modifier .fillMaxWidth() - .clickableAlpha(onClick = onClickAmount) .testTag("SendNumberField") ) @@ -252,7 +237,7 @@ private fun SendAmountNodeRunning( val isLnurl = uiState.lnurl != null if (!isLnurl) { - PaymentMethodButton(uiState = uiState, onEvent = onEvent) + PaymentMethodButton(uiState = uiState, onClick = onClickPayMethod) } if (uiState.lnurl is LnurlParams.LnurlPay) { val max = minOf( @@ -269,6 +254,7 @@ private fun SendAmountNodeRunning( } HorizontalSpacer(8.dp) UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, modifier = Modifier .height(28.dp) .testTag("SendNumberPadUnit") @@ -277,14 +263,8 @@ private fun SendAmountNodeRunning( HorizontalDivider(modifier = Modifier.padding(top = 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, + NumberPad( + viewModel = amountInputViewModel, availableHeight = maxHeight, modifier = Modifier .fillMaxWidth() @@ -294,7 +274,7 @@ private fun SendAmountNodeRunning( PrimaryButton( text = stringResource(R.string.common__continue), enabled = uiState.isAmountInputValid, - onClick = { onEvent(SendEvent.AmountContinue(uiState.amountInput)) }, + onClick = onContinue, modifier = Modifier.testTag("ContinueAmount") ) @@ -306,7 +286,7 @@ private fun SendAmountNodeRunning( @Composable private fun PaymentMethodButton( uiState: SendUiState, - onEvent: (SendEvent) -> Unit, + onClick: () -> Unit, ) { val testId = when { uiState.isUnified -> "switch" @@ -323,7 +303,7 @@ private fun PaymentMethodButton( SendMethod.LIGHTNING -> Colors.Purple }, icon = if (uiState.isUnified) R.drawable.ic_transfer else null, - onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, + onClick = onClick, enabled = uiState.isUnified, modifier = Modifier .height(28.dp) @@ -337,22 +317,13 @@ private fun PreviewLightningNoAmount() { AppThemeSurface { BottomSheetPreview { SendAmountContent( + walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), uiState = SendUiState( payMethod = SendMethod.LIGHTNING, - amountInput = "0", - isAmountInputValid = false, - isUnified = false ), - balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, maxSendLightningSats = 100u), - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), - onBack = {}, - onEvent = {}, - input = "0", - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.FIAT, - currencyUiState = CurrencyUiState(), - onInputChanged = {}, + amountInputViewModel = previewAmountInputViewModel(), modifier = Modifier.sheetHeight(), + balances = BalanceState(maxSendLightningSats = 54_321u), ) } } @@ -363,23 +334,21 @@ private fun PreviewLightningNoAmount() { private fun PreviewUnified() { AppThemeSurface { BottomSheetPreview { + val currencies = remember { + CurrencyState( + displayUnit = BitcoinDisplayUnit.CLASSIC, + ) + } SendAmountContent( + walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), uiState = SendUiState( payMethod = SendMethod.LIGHTNING, - amountInput = "100", - isAmountInputValid = true, isUnified = true, ), - balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, maxSendLightningSats = 100u), - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), - onBack = {}, - onEvent = {}, - input = "100", - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.FIAT, - currencyUiState = CurrencyUiState(), - onInputChanged = {}, + amountInputViewModel = previewAmountInputViewModel(currencies = currencies), modifier = Modifier.sheetHeight(), + balances = BalanceState(maxSendLightningSats = 54_321u), + currencies = currencies, ) } } @@ -391,22 +360,13 @@ private fun PreviewOnchain() { AppThemeSurface { BottomSheetPreview { SendAmountContent( + walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), uiState = SendUiState( payMethod = SendMethod.ONCHAIN, - amountInput = "5000", - isAmountInputValid = true, - isUnified = false ), - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), - balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, maxSendLightningSats = 100u), - onBack = {}, - onEvent = {}, - input = "5000", - currencyUiState = CurrencyUiState(), - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.BITCOIN, - onInputChanged = {}, + amountInputViewModel = previewAmountInputViewModel(), modifier = Modifier.sheetHeight(), + balances = BalanceState(totalOnchainSats = 654_321u), ) } } @@ -418,18 +378,11 @@ private fun PreviewInitializing() { AppThemeSurface { BottomSheetPreview { SendAmountContent( + walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Initializing), uiState = SendUiState( payMethod = SendMethod.LIGHTNING, ), - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Initializing), - balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, maxSendLightningSats = 100u), - onBack = {}, - onEvent = {}, - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.BITCOIN, - input = "100", - currencyUiState = CurrencyUiState(), - onInputChanged = {}, + amountInputViewModel = previewAmountInputViewModel(), modifier = Modifier.sheetHeight(), ) } @@ -442,31 +395,24 @@ private fun PreviewWithdraw() { AppThemeSurface { BottomSheetPreview { SendAmountContent( + walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), uiState = SendUiState( payMethod = SendMethod.LIGHTNING, - amountInput = "100", lnurl = LnurlParams.LnurlWithdraw( data = LnurlWithdrawData( uri = "", callback = "", k1 = "", defaultDescription = "Test", - minWithdrawable = 1u, - maxWithdrawable = 130u, + minWithdrawable = 1_000u, + maxWithdrawable = 51_234_000u, tag = "" ), ), ), - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), - balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, totalLightningSats = 100u), - onBack = {}, - onEvent = {}, - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.BITCOIN, - input = "100", - currencyUiState = CurrencyUiState(), - onInputChanged = {}, + amountInputViewModel = previewAmountInputViewModel(), modifier = Modifier.sheetHeight(), + balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, totalLightningSats = 100u), ) } } @@ -478,9 +424,9 @@ private fun PreviewLnurlPay() { AppThemeSurface { BottomSheetPreview { SendAmountContent( + walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), uiState = SendUiState( payMethod = SendMethod.LIGHTNING, - amountInput = "100", lnurl = LnurlParams.LnurlPay( data = LnurlPayData( uri = "", @@ -494,16 +440,9 @@ private fun PreviewLnurlPay() { ), ), ), - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), - balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, totalLightningSats = 100u), - onBack = {}, - onEvent = {}, - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.BITCOIN, - input = "100", - currencyUiState = CurrencyUiState(), - onInputChanged = {}, + amountInputViewModel = previewAmountInputViewModel(), modifier = Modifier.sheetHeight(), + balances = BalanceState(maxSendLightningSats = 54_321u), ) } } @@ -515,21 +454,13 @@ private fun PreviewSmallScreen() { AppThemeSurface { BottomSheetPreview { SendAmountContent( + walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), uiState = SendUiState( payMethod = SendMethod.LIGHTNING, - amountInput = "100", - isAmountInputValid = true, ), - balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, maxSendLightningSats = 100u), - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), - onBack = {}, - onEvent = {}, - input = "100", - displayUnit = BitcoinDisplayUnit.MODERN, - primaryDisplay = PrimaryDisplay.FIAT, - currencyUiState = CurrencyUiState(), - onInputChanged = {}, + amountInputViewModel = previewAmountInputViewModel(), modifier = Modifier.sheetHeight(), + balances = BalanceState(maxSendLightningSats = 54_321u), ) } } 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 089a31c0b..53f0d5b87 100644 --- a/app/src/main/java/to/bitkit/ui/utils/Text.kt +++ b/app/src/main/java/to/bitkit/ui/utils/Text.kt @@ -21,10 +21,6 @@ import to.bitkit.env.Env import to.bitkit.ext.formatPlural import to.bitkit.ui.theme.AppThemeSurface 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, @@ -140,21 +136,6 @@ fun localizedRandom(@StringRes id: Int): String { } } -fun BigDecimal.formatCurrency(decimalPlaces: Int = 2): String? { - val symbols = DecimalFormatSymbols(Locale.getDefault()).apply { - decimalSeparator = '.' - groupingSeparator = ',' - } - - val decimalPlacesString = "0".repeat(decimalPlaces) - val formatter = DecimalFormat("#,##0.$decimalPlacesString", symbols).apply { - minimumFractionDigits = decimalPlaces - maximumFractionDigits = decimalPlaces - } - - return runCatching { formatter.format(this) }.getOrNull() -} - fun getBlockExplorerUrl( id: String, type: BlockExplorerType = BlockExplorerType.TX, diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt index c647c7446..53c7ef8bf 100644 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt +++ b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt @@ -5,7 +5,7 @@ import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import to.bitkit.models.BitcoinDisplayUnit -import to.bitkit.models.GROUPING_SEPARATOR +import to.bitkit.models.SATS_GROUPING_SEPARATOR import to.bitkit.models.formatToModernDisplay import java.text.DecimalFormat import java.text.DecimalFormatSymbols @@ -36,7 +36,7 @@ class BitcoinVisualTransformation( } private fun formatModernDisplay(text: String): String { - val longValue = text.replace("$GROUPING_SEPARATOR", "").toLongOrNull() ?: return text + val longValue = text.replace("$SATS_GROUPING_SEPARATOR", "").toLongOrNull() ?: return text return longValue.formatToModernDisplay() } diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt index e24c2e220..a20270fe3 100644 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt +++ b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt @@ -3,10 +3,11 @@ package to.bitkit.ui.utils.visualTransformation import to.bitkit.ext.removeSpaces import to.bitkit.ext.toLongOrDefault import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.CLASSIC_DECIMALS import to.bitkit.models.SATS_IN_BTC import to.bitkit.models.asBtc +import to.bitkit.models.formatCurrency import to.bitkit.models.formatToModernDisplay -import to.bitkit.ui.utils.formatCurrency import to.bitkit.viewmodels.CurrencyViewModel import java.math.BigDecimal import java.math.RoundingMode @@ -47,7 +48,7 @@ object CalculatorFormatter { BitcoinDisplayUnit.CLASSIC -> { satsValue.asBtc() - .formatCurrency(decimalPlaces = 8) + .formatCurrency(decimalPlaces = CLASSIC_DECIMALS) .orEmpty() } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt new file mode 100644 index 000000000..0bafe7c9d --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt @@ -0,0 +1,420 @@ +package to.bitkit.viewmodels + +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.ext.toLongOrDefault +import to.bitkit.models.CLASSIC_DECIMALS +import to.bitkit.models.FIAT_DECIMALS +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.SATS_GROUPING_SEPARATOR +import to.bitkit.models.SATS_IN_BTC +import to.bitkit.models.formatToClassicDisplay +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.AmountInputHandler +import to.bitkit.repositories.CurrencyState +import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.components.KEY_DECIMAL +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.NumberPadType +import java.math.BigDecimal +import java.text.NumberFormat +import java.util.Locale +import javax.inject.Inject + +@Suppress("TooManyFunctions") +@HiltViewModel +class AmountInputViewModel @Inject constructor( + private val amountInputHandler: AmountInputHandler, +) : ViewModel() { + companion object { + const val MAX_AMOUNT = 999_999_999L + const val MAX_MODERN_LENGTH = 10 + const val MAX_DECIMAL_LENGTH = 20 + const val ERROR_DELAY_MS = 500L + + const val PLACEHOLDER_CLASSIC = "0.00000000" + const val PLACEHOLDER_MODERN = "0" + const val PLACEHOLDER_FIAT = "0.00" + const val PLACEHOLDER_CLASSIC_DECIMALS = ".00000000" + const val PLACEHOLDER_MODERN_DECIMALS = "" + const val PLACEHOLDER_FIAT_DECIMALS = ".00" + } + + private val _uiState = MutableStateFlow(AmountInputUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var rawInputText: String = "" + + fun handleNumberPadInput( + key: String, + currencyState: CurrencyState, + ) { + val primaryDisplay = currencyState.primaryDisplay + val isModern = currencyState.displayUnit.isModern() + val maxLength = getMaxLength(currencyState) + val maxDecimals = getMaxDecimals(currencyState) + + val newText = handleInput(key = key, current = rawInputText, maxLength, maxDecimals) + + if (newText == rawInputText && key != KEY_DELETE) { + triggerErrorState(key) + return + } + + // For modern Bitcoin (integer input), format the final amount + if (primaryDisplay == PrimaryDisplay.BITCOIN && isModern) { + val newAmount = convertToSats(newText, primaryDisplay, isModern = true) + + if (newAmount <= MAX_AMOUNT) { + rawInputText = newText + _uiState.update { + it.copy( + displayText = formatDisplayTextFromAmount(newAmount, primaryDisplay, isModern = true), + amountSats = newAmount, + errorKey = null + ) + } + } else { + // Block input when limit exceeded + triggerErrorState(key) + } + } else { + // For decimal input, check limits before updating state + if (newText.isNotEmpty()) { + val newAmount = convertToSats(newText, primaryDisplay, isModern) + if (newAmount <= MAX_AMOUNT) { + // Update both raw input and display text + rawInputText = newText + _uiState.update { + it.copy( + displayText = if (primaryDisplay == PrimaryDisplay.FIAT) { + formatFiatGroupingOnly(newText) + } else { + newText + }, + amountSats = newAmount, + errorKey = null + ) + } + } else { + // Block input when limit exceeded + triggerErrorState(key) + } + } else { + // If input is empty, set sats to 0 + rawInputText = newText + _uiState.update { + it.copy( + amountSats = 0, + displayText = "", + errorKey = null + ) + } + } + } + } + + fun setSats(sats: Long, currencyState: CurrencyState) { + val primaryDisplay = currencyState.primaryDisplay + val isModern = currencyState.displayUnit.isModern() + + _uiState.update { + it.copy( + amountSats = sats, + displayText = formatDisplayTextFromAmount(sats, primaryDisplay, isModern) + ) + } + // Update raw input text based on the formatted display + rawInputText = when (primaryDisplay) { + PrimaryDisplay.FIAT -> _uiState.value.displayText.replace(",", "") + else -> _uiState.value.displayText + } + } + + /** + * Toggles between Bitcoin and Fiat display modes while preserving input + */ + fun switchUnit(currencies: CurrencyState) { + viewModelScope.launch { + val currentRawInput = rawInputText + val isModern = currencies.displayUnit.isModern() + val newPrimaryDisplay = amountInputHandler.switchUnit(currencies.primaryDisplay) + + // Update display text when currency changes + val amountSats = _uiState.value.amountSats + if (amountSats > 0) { + _uiState.update { + it.copy( + displayText = formatDisplayTextFromAmount(amountSats, newPrimaryDisplay, isModern) + ) + } + // Update raw input text based on the new display + rawInputText = when (newPrimaryDisplay) { + PrimaryDisplay.FIAT -> _uiState.value.displayText.replace(",", "") + else -> _uiState.value.displayText + } + } else if (currentRawInput.isNotEmpty()) { + // Convert the raw input from the old currency to the new currency + when (newPrimaryDisplay) { + PrimaryDisplay.FIAT -> { + // Converting from bitcoin to fiat + val sats = convertBitcoinToSats(currentRawInput, isModern) + val converted = amountInputHandler.convertSatsToFiatString(sats) + if (converted.isNotEmpty()) { + rawInputText = converted.replace(",", "") + _uiState.update { it.copy(displayText = formatFiatGroupingOnly(rawInputText)) } + } + } + + PrimaryDisplay.BITCOIN -> { + // Converting from fiat to bitcoin + val sats = convertFiatToSats(currentRawInput) + if (sats != null) { + rawInputText = formatBitcoinFromSats(sats, isModern) + _uiState.update { it.copy(displayText = rawInputText) } + } + } + } + } + } + } + + fun getNumberPadType(currencyState: CurrencyState): NumberPadType { + val primaryDisplay = currencyState.primaryDisplay + val isModern = currencyState.displayUnit.isModern() + val isBtc = primaryDisplay == PrimaryDisplay.BITCOIN + return if (isModern && isBtc) NumberPadType.INTEGER else NumberPadType.DECIMAL + } + + fun getMaxLength(currencyState: CurrencyState): Int { + val primaryDisplay = currencyState.primaryDisplay + val isModern = currencyState.displayUnit.isModern() + val isBtc = primaryDisplay == PrimaryDisplay.BITCOIN + return if (isModern && isBtc) MAX_MODERN_LENGTH else MAX_DECIMAL_LENGTH + } + + fun getMaxDecimals(currencyState: CurrencyState): Int { + val primaryDisplay = currencyState.primaryDisplay + val isModern = currencyState.displayUnit.isModern() + val isBtc = primaryDisplay == PrimaryDisplay.BITCOIN + return if (isModern && isBtc) 0 else (if (isBtc) CLASSIC_DECIMALS else FIAT_DECIMALS) + } + + @Suppress("NestedBlockDepth") + fun getPlaceholder(currencyState: CurrencyState): String { + val primaryDisplay = currencyState.primaryDisplay + val isModern = currencyState.displayUnit.isModern() + if (_uiState.value.displayText.isEmpty()) { + return when (primaryDisplay) { + PrimaryDisplay.BITCOIN -> if (isModern) PLACEHOLDER_MODERN else PLACEHOLDER_CLASSIC + PrimaryDisplay.FIAT -> PLACEHOLDER_FIAT + } + } else { + return when (primaryDisplay) { + PrimaryDisplay.BITCOIN -> { + if (isModern) { + PLACEHOLDER_MODERN_DECIMALS + } else { + if (_uiState.value.displayText.contains(".")) { + val parts = _uiState.value.displayText.split(".", limit = 2) + val decimalPart = if (parts.size > 1) parts[1] else "" + val remainingDecimals = CLASSIC_DECIMALS - decimalPart.length + if (remainingDecimals > 0) "0".repeat(remainingDecimals) else "" + } else { + PLACEHOLDER_CLASSIC_DECIMALS + } + } + } + + PrimaryDisplay.FIAT -> { + if (_uiState.value.displayText.contains(".")) { + val parts = _uiState.value.displayText.split(".", limit = 2) + val decimalPart = if (parts.size > 1) parts[1] else "" + val remainingDecimals = FIAT_DECIMALS - decimalPart.length + if (remainingDecimals > 0) "0".repeat(remainingDecimals) else "" + } else { + PLACEHOLDER_FIAT_DECIMALS + } + } + } + } + } + + fun clearInput() { + rawInputText = "" + _uiState.update { AmountInputUiState() } + } + + private fun triggerErrorState(key: String) { + _uiState.update { it.copy(errorKey = key) } + viewModelScope.launch { + delay(ERROR_DELAY_MS) + _uiState.update { it.copy(errorKey = null) } + } + } + + private fun formatDisplayTextFromAmount( + amountSats: Long, + primaryDisplay: PrimaryDisplay, + isModern: Boolean, + ): String { + if (amountSats == 0L) return "" + return when (primaryDisplay) { + PrimaryDisplay.BITCOIN -> formatBitcoinFromSats(amountSats, isModern) + PrimaryDisplay.FIAT -> amountInputHandler.convertSatsToFiatString(amountSats) + } + } + + @Suppress("ReturnCount") + private fun formatFiatGroupingOnly(text: String): String { + // Remove any existing grouping separators for parsing + val cleanText = text.replace(",", "") + + // If the text ends with a decimal point, don't format it (preserve the decimal point) + if (text.endsWith(".")) { + // Only add grouping separators to the integer part + val integerPart = cleanText.dropLast(1) // Remove the decimal point + integerPart.toIntOrNull()?.let { intValue -> + val formatter = NumberFormat.getNumberInstance(Locale.US) + return formatter.format(intValue) + "." + } + return text + } + + // If the text contains a decimal point, preserve the decimal structure + if (text.contains(".")) { + val parts = cleanText.split(".", limit = 2) + val integerPart = parts[0] + val decimalPart = if (parts.size > 1) parts[1] else "" + + // Format only the integer part with grouping separators + integerPart.toIntOrNull()?.let { intValue -> + val formatter = NumberFormat.getNumberInstance(Locale.US) + return formatter.format(intValue) + "." + decimalPart + } + return text + } + + // For integer-only input, add grouping separators + cleanText.toIntOrNull()?.let { intValue -> + val formatter = NumberFormat.getNumberInstance(Locale.US) + return formatter.format(intValue) + } + + return text + } + + private fun formatBitcoinFromSats(sats: Long, isModern: Boolean): String { + return if (isModern) sats.formatToModernDisplay() else sats.formatToClassicDisplay() + } + + private fun convertToSats( + text: String, + primaryDisplay: PrimaryDisplay, + isModern: Boolean, + ): Long { + if (text.isEmpty()) return 0L + return when (primaryDisplay) { + PrimaryDisplay.BITCOIN -> convertBitcoinToSats(text, isModern) + PrimaryDisplay.FIAT -> convertFiatToSats(text) ?: 0 + } + } + + private fun convertBitcoinToSats(text: String, isModern: Boolean): Long { + if (text.isEmpty()) return 0 + + return if (isModern) { + text.replace("$SATS_GROUPING_SEPARATOR", "").toLongOrDefault() + } else { + runCatching { + val btcBigDecimal = BigDecimal(text) + val satsBigDecimal = btcBigDecimal.multiply(BigDecimal(SATS_IN_BTC)) + satsBigDecimal.toLong() + }.getOrDefault(0) + } + } + + private fun convertFiatToSats(text: String): Long? { + return text.replace(",", "") + .toDoubleOrNull() + ?.let { fiat -> amountInputHandler.convertFiatToSats(fiat) } + } + + private fun handleInput( + key: String, + current: String, + maxLength: Int, + maxDecimals: Int, + ): String { + return if (maxDecimals == 0) { + handleIntegerInput(key, current, maxLength) + } else { + handleDecimalInput(key, current, maxLength, maxDecimals) + } + } + + private fun handleIntegerInput(key: String, current: String, maxLength: Int): String { + if (key == KEY_DELETE) return current.dropLast(1) + + if (current == "0") return key + if (current.length >= maxLength) return current + + return current + key + } + + @Suppress("ReturnCount") + private fun handleDecimalInput( + key: String, + current: String, + maxLength: Int, + maxDecimals: Int, + ): String { + val parts = current.split(".", limit = 2) + val decimalPart = if (parts.size > 1) parts[1] else "" + + if (key == KEY_DELETE) { + if (current == "0.") return "" + return current.dropLast(1) + } + + // Handle leading zeros - replace "0" with new digit but allow "0." + if (current == "0" && key != ".") return key + + // Limit to maxLength + if (current.length >= maxLength) return current + + // Limit decimal places + if (decimalPart.length >= maxDecimals) return current + + if (key == KEY_DECIMAL) { + // No multiple decimal symbols + if (current.contains(".")) return current + // Add leading zero + if (current.isEmpty()) return "0." + } + + return current + key + } +} + +data class AmountInputUiState( + val amountSats: Long = 0L, + val displayText: String = "", + val errorKey: String? = null, +) + +@Composable +fun previewAmountInputViewModel( + sats: Long = 4_567, + currencies: CurrencyState = LocalCurrencies.current, +) = AmountInputViewModel(AmountInputHandler.stub()).also { + it.setSats(sats, currencies) +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ff9c8804c..f6b298588 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -295,9 +295,9 @@ class AppViewModel @Inject constructor( SendEvent.AddressReset -> resetAddressInput() is SendEvent.AddressContinue -> onAddressContinue(it.data) - is SendEvent.AmountChange -> onAmountChange(it.value) + is SendEvent.AmountChange -> onAmountChange(it.amount) SendEvent.AmountReset -> resetAmountInput() - is SendEvent.AmountContinue -> onAmountContinue(it.amount) + SendEvent.AmountContinue -> onAmountContinue() SendEvent.PaymentMethodSwitch -> onPaymentMethodSwitch() is SendEvent.CoinSelectionContinue -> onCoinSelectionContinue(it.utxos) @@ -351,11 +351,11 @@ class AppViewModel @Inject constructor( } } - private fun onAmountChange(value: String) { + private fun onAmountChange(amount: ULong) { _sendUiState.update { it.copy( - amountInput = value, - isAmountInputValid = validateAmount(value) + amount = amount, + isAmountInputValid = validateAmount(amount), ) } } @@ -413,15 +413,14 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( payMethod = nextPaymentMethod, - isAmountInputValid = validateAmount(it.amountInput, nextPaymentMethod), + isAmountInputValid = validateAmount(it.amount, nextPaymentMethod), ) } } - private suspend fun onAmountContinue(amount: String) { + private suspend fun onAmountContinue() { _sendUiState.update { it.copy( - amount = amount.toULongOrNull() ?: 0u, selectedUtxos = null, ) } @@ -452,26 +451,21 @@ class AppViewModel @Inject constructor( } private fun validateAmount( - value: String, + amount: ULong, payMethod: SendMethod = _sendUiState.value.payMethod, ): Boolean { - if (value.isBlank()) return false - val amount = value.toULongOrNull() ?: return false if (amount == 0uL) return false return when (payMethod) { SendMethod.LIGHTNING -> when (val lnurl = _sendUiState.value.lnurl) { null -> lightningRepo.canSend(amount) + is LnurlParams.LnurlWithdraw -> amount < lnurl.data.maxWithdrawableSat() is LnurlParams.LnurlPay -> { val minSat = lnurl.data.minSendableSat() val maxSat = lnurl.data.maxSendableSat() amount in minSat..maxSat && lightningRepo.canSend(amount) } - - is LnurlParams.LnurlWithdraw -> { - amount < lnurl.data.maxWithdrawableSat() - } } SendMethod.ONCHAIN -> amount > Env.TransactionDefaults.dustLimit.toULong() @@ -829,8 +823,8 @@ class AppViewModel @Inject constructor( private fun resetAmountInput() { _sendUiState.update { state -> state.copy( - amountInput = state.amount.toString(), - isAmountInputValid = validateAmount(state.amount.toString()), + amount = 0u, + isAmountInputValid = false, ) } } @@ -1520,7 +1514,6 @@ data class SendUiState( val addressInput: String = "", val isAddressInputValid: Boolean = false, val amount: ULong = 0u, - val amountInput: String = "", val isAmountInputValid: Boolean = false, val isUnified: Boolean = false, val payMethod: SendMethod = SendMethod.ONCHAIN, @@ -1585,8 +1578,8 @@ sealed interface SendEvent { data class AddressContinue(val data: String) : SendEvent data object AmountReset : SendEvent - data class AmountContinue(val amount: String) : SendEvent - data class AmountChange(val value: String) : SendEvent + data object AmountContinue : SendEvent + data class AmountChange(val amount: ULong) : SendEvent data class CoinSelectionContinue(val utxos: List) : SendEvent diff --git a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt index 3f3015d74..92af4aee6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt @@ -25,9 +25,9 @@ class CurrencyViewModel @Inject constructor( } } - fun togglePrimaryDisplay() { + fun switchUnit() { viewModelScope.launch { - currencyRepo.togglePrimaryDisplay() + currencyRepo.switchUnit() } } @@ -59,6 +59,3 @@ class CurrencyViewModel @Inject constructor( return uLongSats.toLong() } } - -// For backward compatibility, keeping the original data class name -typealias CurrencyUiState = CurrencyState diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 6b261a0e0..9e60aa2ac 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -49,6 +49,7 @@ import kotlin.time.Duration.Companion.seconds const val RETRY_INTERVAL_MS = 1 * 60 * 1000L // 1 minutes in ms const val GIVE_UP_MS = 30 * 60 * 1000L // 30 minutes in ms private const val EUR_CURRENCY = "EUR" +private const val QUARTER = 0.25 @HiltViewModel class TransferViewModel @Inject constructor( @@ -81,40 +82,7 @@ class TransferViewModel @Inject constructor( // region Spending - fun onClickMaxAmount() { - _spendingUiState.update { - it.copy( - satsAmount = it.maxAllowedToSend, - overrideSats = it.maxAllowedToSend, - ) - } - updateLimits() - } - - fun onClickQuarter() { - val quarter = (_spendingUiState.value.balanceAfterFee.toDouble() * QUARTER).roundToLong() - - if (quarter > _spendingUiState.value.maxAllowedToSend) { - setTransferEffect( - TransferEffect.ToastError( - title = context.getString(R.string.lightning__spending_amount__error_max__title), - description = context.getString( - R.string.lightning__spending_amount__error_max__description - ).replace("{amount}", _spendingUiState.value.maxAllowedToSend.toString()), - ) - ) - } - - _spendingUiState.update { - it.copy( - satsAmount = min(quarter, it.maxAllowedToSend), - overrideSats = min(quarter, it.maxAllowedToSend), - ) - } - updateLimits() - } - - fun onConfirmAmount() { + fun onConfirmAmount(satsAmount: Long) { if (_transferValues.value.maxLspBalance == 0uL) { setTransferEffect( TransferEffect.ToastError( @@ -129,8 +97,8 @@ class TransferViewModel @Inject constructor( viewModelScope.launch { _spendingUiState.update { it.copy(isLoading = true) } - val minAmount = getMinAmountToPurchase() - if (_spendingUiState.value.satsAmount < minAmount) { + val minAmount = getMinAmountToPurchase(satsAmount) + if (satsAmount < minAmount) { setTransferEffect( TransferEffect.ToastError( title = context.getString(R.string.lightning__spending_amount__error_min__title), @@ -139,7 +107,7 @@ class TransferViewModel @Inject constructor( ).replace("{amount}", "$minAmount"), ) ) - _spendingUiState.update { it.copy(overrideSats = minAmount, isLoading = false) } + _spendingUiState.update { it.copy(isLoading = false) } return@launch } @@ -147,7 +115,7 @@ class TransferViewModel @Inject constructor( isNodeRunning.first { it } } - blocktankRepo.createOrder(_spendingUiState.value.satsAmount.toULong()) + blocktankRepo.createOrder(satsAmount.toULong()) .onSuccess { order -> settingsStore.update { it.copy(lightningSetupStep = 0) } onOrderCreated(order) @@ -161,31 +129,8 @@ class TransferViewModel @Inject constructor( } } - fun onInputChanged(newInput: String) { - _spendingUiState.update { it.copy(input = newInput) } - } - - fun handleCalculatedAmount(sats: Long) { - if (sats > _spendingUiState.value.maxAllowedToSend) { - setTransferEffect( - TransferEffect.ToastError( - title = context.getString(R.string.lightning__spending_amount__error_max__title), - description = context.getString( - R.string.lightning__spending_amount__error_max__description - ).replace("{amount}", _spendingUiState.value.maxAllowedToSend.toString()), - ) - ) - _spendingUiState.update { it.copy(overrideSats = it.satsAmount) } - return - } - - _spendingUiState.update { it.copy(satsAmount = sats, overrideSats = null) } - - updateLimits() - } - - fun updateLimits() { - updateTransferValues(_spendingUiState.value.satsAmount.toULong()) + fun updateLimits(satsAmount: Long = 0) { + updateTransferValues(satsAmount.toULong()) updateAvailableAmount() } @@ -257,8 +202,8 @@ class TransferViewModel @Inject constructor( } } - private suspend fun getMinAmountToPurchase(): Long { - val fee = lightningRepo.calculateTotalFee(_spendingUiState.value.satsAmount.toULong()).getOrNull() ?: 0u + private suspend fun getMinAmountToPurchase(satsAmount: Long = 0L): Long { + val fee = lightningRepo.calculateTotalFee(satsAmount.toULong()).getOrNull() ?: 0u return max((fee + maxLspFee).toLong(), Env.TransactionDefaults.dustLimit.toLong()) } @@ -537,7 +482,6 @@ class TransferViewModel @Inject constructor( companion object { private const val TAG = "TransferViewModel" - private const val QUARTER = 0.25 } } @@ -546,13 +490,12 @@ data class TransferToSpendingUiState( val order: IBtOrder? = null, val defaultOrder: IBtOrder? = null, val isAdvanced: Boolean = false, - val satsAmount: Long = 0, - val overrideSats: Long? = null, val maxAllowedToSend: Long = 0, val balanceAfterFee: Long = 0, val isLoading: Boolean = false, - val input: String = "", -) +) { + fun balanceAfterFeeQuarter() = (balanceAfterFee.toDouble() * QUARTER).roundToLong() +} data class TransferValues( val defaultLspBalance: ULong = 0u, diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index d966ccdbc..c3d38463e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -74,7 +74,6 @@ class WalletViewModel @Inject constructor( _uiState.update { it.copy( onchainAddress = state.onchainAddress, - balanceInput = state.balanceInput, bolt11 = state.bolt11, bip21 = state.bip21, bip21AmountSats = state.bip21AmountSats, @@ -286,10 +285,6 @@ class WalletViewModel @Inject constructor( walletRepo.updateBip21Description(newText) } - fun updateBalanceInput(newText: String) { - walletRepo.updateBalanceInput(newText = newText) - } - suspend fun handleHideBalanceOnOpen() { val hideBalanceOnOpen = settingsStore.data.map { it.hideBalanceOnOpen }.first() if (hideBalanceOnOpen) { @@ -301,7 +296,6 @@ class WalletViewModel @Inject constructor( // TODO rename to walletUiState data class MainUiState( val nodeId: String = "", - val balanceInput: String = "", val balanceDetails: BalanceDetails? = null, val onchainAddress: String = "", val bolt11: String = "", diff --git a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt new file mode 100644 index 000000000..6c35c968c --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt @@ -0,0 +1,975 @@ +package to.bitkit.viewmodels + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.datetime.Clock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.FIAT_DECIMALS +import to.bitkit.models.FxRate +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.STUB_RATE +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState +import to.bitkit.services.CurrencyService +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.components.KEY_000 +import to.bitkit.ui.components.KEY_DECIMAL +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.NumberPadType +import kotlin.time.Duration.Companion.milliseconds + +@Suppress("LargeClass") +class AmountInputViewModelTest : BaseUnitTest() { + private lateinit var viewModel: AmountInputViewModel + private val currencyService: CurrencyService = mock() + private val settingsStore: SettingsStore = mock() + private val cacheStore: CacheStore = mock() + private val clock: Clock = mock() + private lateinit var currencyRepo: CurrencyRepo + + private val testRates = listOf( + FxRate( + symbol = "BTCUSD", + lastPrice = STUB_RATE.toString(), + base = "BTC", + baseName = "Bitcoin", + quote = "USD", + quoteName = "US Dollar", + currencySymbol = "$", + currencyFlag = "🇺🇸", + lastUpdatedAt = System.currentTimeMillis() + ) + ) + + @Before + fun setUp() { + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + + currencyRepo = CurrencyRepo( + bgDispatcher = testDispatcher, + currencyService = currencyService, + settingsStore = settingsStore, + cacheStore = cacheStore, + enablePolling = false, + clock = clock + ) + + viewModel = AmountInputViewModel(currencyRepo) + } + + private fun mockCurrency( + primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN, + displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, + ) = CurrencyState( + rates = testRates, + selectedCurrency = "USD", + currencySymbol = "$", + primaryDisplay = primaryDisplay, + displayUnit = displayUnit + ) + + // MARK: - Modern Bitcoin Tests + + @Test + fun `modern bitcoin input builds correctly`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.handleNumberPadInput("1", currency) + assertEquals("1", viewModel.uiState.value.displayText) + assertEquals(1L, viewModel.uiState.value.amountSats) + + viewModel.handleNumberPadInput("0", currency) + assertEquals("10", viewModel.uiState.value.displayText) + assertEquals(10L, viewModel.uiState.value.amountSats) + + viewModel.handleNumberPadInput("0", currency) + assertEquals("100", viewModel.uiState.value.displayText) + assertEquals(100L, viewModel.uiState.value.amountSats) + + viewModel.handleNumberPadInput("0", currency) + assertEquals("1 000", viewModel.uiState.value.displayText) + assertEquals(1000L, viewModel.uiState.value.amountSats) + } + + @Test + fun `modern bitcoin max amount enforcement`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + // Type max amount + "999999999".forEach { digit -> + viewModel.handleNumberPadInput(digit.toString(), currency) + } + assertEquals(AmountInputViewModel.MAX_AMOUNT, viewModel.uiState.value.amountSats) + + // Try to exceed max amount - should be blocked + viewModel.handleNumberPadInput("0", currency) + assertEquals(AmountInputViewModel.MAX_AMOUNT, viewModel.uiState.value.amountSats) + assertNotNull(viewModel.uiState.value.errorKey) + } + + @Test + fun `modern bitcoin number pad type is INTEGER`() { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + assertEquals(NumberPadType.INTEGER, viewModel.getNumberPadType(currency)) + } + + @Test + fun `modern bitcoin allows 000 button`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput(KEY_000, currency) + assertEquals("1 000", viewModel.uiState.value.displayText) + assertEquals(1000L, viewModel.uiState.value.amountSats) + } + + // MARK: - Classic Bitcoin Tests + + @Test + fun `classic bitcoin decimal input`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + + viewModel.handleNumberPadInput("1", currency) + assertEquals("1", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + assertEquals("1.", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput("0", currency) + assertEquals("1.0", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput("0", currency) + assertEquals("1.00", viewModel.uiState.value.displayText) + } + + @Test + fun `classic bitcoin max decimals enforcement`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + + // Build up to max decimals + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + + repeat(8) { + viewModel.handleNumberPadInput("0", currency) + } + assertEquals("1.00000000", viewModel.uiState.value.displayText) + + // Try to add more decimals - should be blocked + viewModel.handleNumberPadInput("0", currency) + assertEquals("1.00000000", viewModel.uiState.value.displayText) // Should not change + } + + @Test + fun `classic bitcoin max amount enforcement`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + + // Type "10" in classic Bitcoin - should be blocked (exceeds max amount) + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput("0", currency) + + // Should not allow "10" because 10 BTC > 999,999,999 sats + assertTrue(viewModel.uiState.value.amountSats <= AmountInputViewModel.MAX_AMOUNT) + } + + @Test + fun `classic bitcoin number pad type is DECIMAL`() { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + assertEquals(NumberPadType.DECIMAL, viewModel.getNumberPadType(currency)) + } + + // MARK: - Fiat Tests + + @Test + fun `fiat number pad type is DECIMAL`() { + val currency = mockCurrency(PrimaryDisplay.FIAT) + assertEquals(NumberPadType.DECIMAL, viewModel.getNumberPadType(currency)) + } + + @Test + fun `fiat max decimals is 2`() { + val currency = mockCurrency(PrimaryDisplay.FIAT) + assertEquals(FIAT_DECIMALS, viewModel.getMaxDecimals(currency)) + } + + // MARK: - Edge Cases + + @Test + fun `empty input plus decimal becomes 0 point`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + assertEquals("0.", viewModel.uiState.value.displayText) + } + + @Test + fun `leading zeros are prevented`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + // Start with 0, then type a digit - should replace 0 + viewModel.handleNumberPadInput("0", currency) + assertEquals("", viewModel.uiState.value.displayText) // Modern Bitcoin shows empty for 0 + + viewModel.handleNumberPadInput("1", currency) + assertEquals("1", viewModel.uiState.value.displayText) + } + + @Test + fun `delete from 0 point clears entire input`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + viewModel.handleNumberPadInput("0", currency) + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + assertEquals("0.", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals("", viewModel.uiState.value.displayText) + } + + @Test + fun `delete operations work correctly`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + + // Build up "1.50" + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + viewModel.handleNumberPadInput("5", currency) + viewModel.handleNumberPadInput("0", currency) + assertEquals("1.50", viewModel.uiState.value.displayText) + + // Delete back to "1." + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals("1.5", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals("1.", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals("1", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals("", viewModel.uiState.value.displayText) + } + + @Test + fun `multiple decimal points are ignored`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + viewModel.handleNumberPadInput("5", currency) + assertEquals("1.5", viewModel.uiState.value.displayText) + + // Try to add another decimal point + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + assertEquals("1.5", viewModel.uiState.value.displayText) // Should not change + } + + @Test + fun `error state clears automatically`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + // Type max amount + "999999999".forEach { digit -> + viewModel.handleNumberPadInput(digit.toString(), currency) + } + + // Try to exceed max amount - should trigger error + viewModel.handleNumberPadInput("0", currency) + assertNotNull(viewModel.uiState.value.errorKey) + assertEquals("0", viewModel.uiState.value.errorKey) + + // Wait for error to clear + delay(AmountInputViewModel.ERROR_DELAY_MS + 100) + assertNull(viewModel.uiState.value.errorKey) + } + + @Test + fun `placeholder shows correctly for different currencies`() { + // Modern Bitcoin - empty input + val modernBtc = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + assertEquals("0", viewModel.getPlaceholder(modernBtc)) + + // Classic Bitcoin - empty input + val classicBtc = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + assertEquals( + "0.00000000", + viewModel.getPlaceholder(classicBtc) + ) + + // Fiat - empty input + val fiat = mockCurrency(PrimaryDisplay.FIAT) + assertEquals("0.00", viewModel.getPlaceholder(fiat)) + } + + @Test + fun `max length enforcement`() { + // Modern Bitcoin max length + val modernBtc = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + assertEquals(AmountInputViewModel.MAX_MODERN_LENGTH, viewModel.getMaxLength(modernBtc)) + + // Classic Bitcoin max length + val classicBtc = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + assertEquals(AmountInputViewModel.MAX_DECIMAL_LENGTH, viewModel.getMaxLength(classicBtc)) + + // Fiat max length + val fiat = mockCurrency(PrimaryDisplay.FIAT) + assertEquals(AmountInputViewModel.MAX_DECIMAL_LENGTH, viewModel.getMaxLength(fiat)) + } + + @Test + fun `clear input resets all state`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput("0", currency) + viewModel.handleNumberPadInput("0", currency) + + assertEquals("100", viewModel.uiState.value.displayText) + assertEquals(100L, viewModel.uiState.value.amountSats) + + viewModel.clearInput() + + assertEquals("", viewModel.uiState.value.displayText) + assertEquals(0L, viewModel.uiState.value.amountSats) + assertNull(viewModel.uiState.value.errorKey) + } + + @Test + fun `setSats sets correct display text`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.setSats(12345L, currency) + assertEquals(12345L, viewModel.uiState.value.amountSats) + assertEquals("12 345", viewModel.uiState.value.displayText) + } + + @Test + fun `setSats works with fiat currency`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(primaryDisplay = PrimaryDisplay.FIAT))) + + viewModel.setSats(100000L, currency) + assertEquals(100000L, viewModel.uiState.value.amountSats) + assertEquals("115.15", viewModel.uiState.value.displayText) + + viewModel.switchUnit(currency) + assertEquals("100 000", viewModel.uiState.value.displayText) + } + + @Test + fun `fiat grouping separators work correctly`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + viewModel.handleNumberPadInput("1", currency) + assertEquals("1", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput("0", currency) + assertEquals("10", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput("0", currency) + assertEquals("100", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput("0", currency) + assertEquals("1,000", viewModel.uiState.value.displayText) + } + + @Test + fun `delete from formatted text works correctly`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // Build up to "1,000.00" + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput("0", currency) + viewModel.handleNumberPadInput("0", currency) + viewModel.handleNumberPadInput("0", currency) + assertEquals("1,000", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + viewModel.handleNumberPadInput("0", currency) + viewModel.handleNumberPadInput("0", currency) + assertEquals("1,000.00", viewModel.uiState.value.displayText) + + // Delete character by character + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals("1,000.0", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals("1,000.", viewModel.uiState.value.displayText) + + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals("1,000", viewModel.uiState.value.displayText) + } + + @Test + fun `multiple leading zeros behavior`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // Multiple zeros should be ignored until a non-zero digit is entered + viewModel.handleNumberPadInput("0", currency) + viewModel.handleNumberPadInput("0", currency) + viewModel.handleNumberPadInput("0", currency) + viewModel.handleNumberPadInput("1", currency) + assertEquals("1", viewModel.uiState.value.displayText) + } + + @Test + fun `empty input plus decimal becomes 0 point for all currencies`() = test { + // Test for fiat + val fiatCurrency = mockCurrency(PrimaryDisplay.FIAT) + viewModel.handleNumberPadInput(KEY_DECIMAL, fiatCurrency) + assertEquals("0.", viewModel.uiState.value.displayText) + + // Clear and test for classic Bitcoin + viewModel.clearInput() + val classicBtc = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + viewModel.handleNumberPadInput(KEY_DECIMAL, classicBtc) + assertEquals("0.", viewModel.uiState.value.displayText) + } + + @Test + fun `dynamic placeholder behavior for classic bitcoin`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + + // Empty input should show full decimal placeholder + assertEquals("0.00000000", viewModel.getPlaceholder(currency)) + + // Typing "1" should show remaining decimals + viewModel.handleNumberPadInput("1", currency) + assertEquals(".00000000", viewModel.getPlaceholder(currency)) + + // Typing "1." should show remaining decimals + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + assertEquals("00000000", viewModel.getPlaceholder(currency)) + + // Typing "1.5" should show remaining decimals + viewModel.handleNumberPadInput("5", currency) + assertEquals("0000000", viewModel.getPlaceholder(currency)) + } + + @Test + fun `dynamic placeholder behavior for fiat`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // Empty input should show decimal placeholder + assertEquals("0.00", viewModel.getPlaceholder(currency)) + + // Typing "1" should show decimal placeholder + viewModel.handleNumberPadInput("1", currency) + assertEquals(".00", viewModel.getPlaceholder(currency)) + + // Typing "1." should show remaining decimals + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + assertEquals("00", viewModel.getPlaceholder(currency)) + + // Typing "1.5" should show remaining decimal + viewModel.handleNumberPadInput("5", currency) + assertEquals("0", viewModel.getPlaceholder(currency)) + } + + @Test + fun `placeholder with leading zero for fiat`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // "0" should show decimal placeholder + viewModel.handleNumberPadInput("0", currency) + assertEquals(".00", viewModel.getPlaceholder(currency)) + + // "0." should show remaining decimals + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + assertEquals("00", viewModel.getPlaceholder(currency)) + } + + @Test + fun `placeholder after delete operations`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // Build up to "1.50" + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + viewModel.handleNumberPadInput("5", currency) + viewModel.handleNumberPadInput("0", currency) + assertEquals("", viewModel.getPlaceholder(currency)) + + // Delete to "1.5" should show remaining decimal + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals("0", viewModel.getPlaceholder(currency)) + + // Delete to "1." should show remaining decimals + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals("00", viewModel.getPlaceholder(currency)) + + // Delete to "1" should show decimal placeholder + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals(".00", viewModel.getPlaceholder(currency)) + } + + // MARK: - Blocked Input Tests (State Should Not Change) + + @Test + fun `blocked input in fiat with full decimals doesn't change amountSats`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // Build up to fiat with 2 decimals: "24.21" + viewModel.handleNumberPadInput("2", currency) + viewModel.handleNumberPadInput("4", currency) + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + viewModel.handleNumberPadInput("2", currency) + viewModel.handleNumberPadInput("1", currency) + + val initialDisplay = viewModel.uiState.value.displayText + val initialSats = viewModel.uiState.value.amountSats + + // Try to add another digit - should be blocked + viewModel.handleNumberPadInput("5", currency) + + // Both display and sats should remain unchanged + assertEquals(initialDisplay, viewModel.uiState.value.displayText) + assertEquals(initialSats, viewModel.uiState.value.amountSats) + } + + @Test + fun `blocked input in classic bitcoin with 8 decimals doesn't change state`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + + // Build up to classic Bitcoin with 8 decimals: "0.12345678" + viewModel.handleNumberPadInput("0", currency) + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + repeat(8) { digit -> + viewModel.handleNumberPadInput((digit + 1).toString(), currency) + } + + val initialDisplay = viewModel.uiState.value.displayText + val initialSats = viewModel.uiState.value.amountSats + + // Try to add another decimal digit - should be blocked + viewModel.handleNumberPadInput("9", currency) + + // Both display and sats should remain unchanged + assertEquals(initialDisplay, viewModel.uiState.value.displayText) + assertEquals(initialSats, viewModel.uiState.value.amountSats) + } + + @Test + fun `blocked input at max decimal length doesn't change state`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // Build up to max decimal length (20 characters) + val maxLengthText = "1".repeat(AmountInputViewModel.MAX_DECIMAL_LENGTH) + maxLengthText.forEach { digit -> + if (viewModel.uiState.value.displayText.length < AmountInputViewModel.MAX_DECIMAL_LENGTH) { + viewModel.handleNumberPadInput(digit.toString(), currency) + } + } + + val initialDisplay = viewModel.uiState.value.displayText + val initialSats = viewModel.uiState.value.amountSats + + // Try to add another digit - should be blocked + viewModel.handleNumberPadInput("9", currency) + + // Both display and sats should remain unchanged + assertEquals(initialDisplay, viewModel.uiState.value.displayText) + assertEquals(initialSats, viewModel.uiState.value.amountSats) + } + + @Test + fun `blocked input at max modern bitcoin length doesn't change state`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + // Build up to max modern length (10 digits) + val maxLengthText = "1".repeat(AmountInputViewModel.MAX_MODERN_LENGTH) + maxLengthText.forEach { digit -> + viewModel.handleNumberPadInput(digit.toString(), currency) + } + + val initialDisplay = viewModel.uiState.value.displayText + val initialSats = viewModel.uiState.value.amountSats + + // Try to add another digit - should be blocked + viewModel.handleNumberPadInput("9", currency) + + // Both display and sats should remain unchanged + assertEquals(initialDisplay, viewModel.uiState.value.displayText) + assertEquals(initialSats, viewModel.uiState.value.amountSats) + } + + @Test + fun `blocked decimal point when already exists doesn't change state`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // Build up to "12.34" + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput("2", currency) + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + viewModel.handleNumberPadInput("3", currency) + viewModel.handleNumberPadInput("4", currency) + + val initialDisplay = viewModel.uiState.value.displayText + val initialSats = viewModel.uiState.value.amountSats + + // Try to add another decimal point - should be blocked + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + + // Both display and sats should remain unchanged + assertEquals(initialDisplay, viewModel.uiState.value.displayText) + assertEquals(initialSats, viewModel.uiState.value.amountSats) + } + + @Test + fun `blocked leading zeros don't change state`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // Start with "0" + viewModel.handleNumberPadInput("0", currency) + + val initialDisplay = viewModel.uiState.value.displayText + val initialSats = viewModel.uiState.value.amountSats + + // Try to add another "0" - should be blocked (replaced with same value) + viewModel.handleNumberPadInput("0", currency) + + // Both display and sats should remain unchanged + assertEquals(initialDisplay, viewModel.uiState.value.displayText) + assertEquals(initialSats, viewModel.uiState.value.amountSats) + } + + @Test + fun `blocked triple zero button when exceeding limits doesn't change state`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + // Build up to 8 digits (close to max of 10) + "12345678".forEach { digit -> + viewModel.handleNumberPadInput(digit.toString(), currency) + } + + val initialDisplay = viewModel.uiState.value.displayText + val initialSats = viewModel.uiState.value.amountSats + + // Try to add "000" - should be blocked (would exceed max length) + viewModel.handleNumberPadInput(KEY_000, currency) + + // Both display and sats should remain unchanged + assertEquals(initialDisplay, viewModel.uiState.value.displayText) + assertEquals(initialSats, viewModel.uiState.value.amountSats) + } + + @Test + fun `toggle currency then blocked input preserves original amount`() = test { + val modernBtc = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + val fiat = mockCurrency(PrimaryDisplay.FIAT) + + // Enter modern Bitcoin amount + viewModel.handleNumberPadInput("1", modernBtc) + viewModel.handleNumberPadInput("0", modernBtc) + viewModel.handleNumberPadInput("0", modernBtc) + val originalSats = viewModel.uiState.value.amountSats + + // Simulate toggle to fiat (would show something like "1.00" with 2 decimals) + viewModel.setSats(originalSats, fiat) + + // Try to add another digit in fiat mode - should be blocked if at decimal limit + viewModel.handleNumberPadInput(KEY_DECIMAL, fiat) + viewModel.handleNumberPadInput("0", fiat) + viewModel.handleNumberPadInput("0", fiat) + + val fiatSatsAfterFullInput = viewModel.uiState.value.amountSats + + // Try to add another digit - should be blocked + viewModel.handleNumberPadInput("5", fiat) + + // Sats amount should not change from the previous valid state + assertEquals(fiatSatsAfterFullInput, viewModel.uiState.value.amountSats) + } + + @Test + fun `delete key works even at input limits`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // Build up to fiat with 2 decimals: "12.34" + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput("2", currency) + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + viewModel.handleNumberPadInput("3", currency) + viewModel.handleNumberPadInput("4", currency) + + val initialSats = viewModel.uiState.value.amountSats + + // Delete should still work even at decimal limit + viewModel.handleNumberPadInput(KEY_DELETE, currency) + + // Display should change and sats should be different + assertEquals("12.3", viewModel.uiState.value.displayText) + assertTrue(viewModel.uiState.value.amountSats != initialSats) + } + + @Test + fun `blocked decimals beyond fiat limit doesn't change state`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + // Build up to "1.23" (max 2 decimals for fiat) + viewModel.handleNumberPadInput("1", currency) + viewModel.handleNumberPadInput(KEY_DECIMAL, currency) + viewModel.handleNumberPadInput("2", currency) + viewModel.handleNumberPadInput("3", currency) + + val initialDisplay = viewModel.uiState.value.displayText + val initialSats = viewModel.uiState.value.amountSats + + // Try to add more decimal digits - should be blocked + viewModel.handleNumberPadInput("4", currency) + assertEquals(initialDisplay, viewModel.uiState.value.displayText) + assertEquals(initialSats, viewModel.uiState.value.amountSats) + + viewModel.handleNumberPadInput("5", currency) + assertEquals(initialDisplay, viewModel.uiState.value.displayText) + assertEquals(initialSats, viewModel.uiState.value.amountSats) + } + + @Test + fun `switchUnit preserves sats amount`() = test { + val btc = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + val fiat = mockCurrency(PrimaryDisplay.FIAT) + + // Enter Bitcoin amount + viewModel.handleNumberPadInput("0", btc) + viewModel.handleNumberPadInput(KEY_DECIMAL, btc) + viewModel.handleNumberPadInput("0", btc) + viewModel.handleNumberPadInput("1", btc) + val originalSats = viewModel.uiState.value.amountSats + + // Toggle to fiat + viewModel.switchUnit(fiat) + val satsAfterToggle = viewModel.uiState.value.amountSats + + // Toggle back to bitcoin + viewModel.switchUnit(btc) + val finalSats = viewModel.uiState.value.amountSats + + // Sats amount should be preserved throughout + assertEquals(originalSats, satsAfterToggle) + assertEquals(originalSats, finalSats) + assertTrue("Amount should be greater than 0", originalSats > 0) + } + + @Test + fun `classic round trip conversion maintains original amount`() = test { + val btcClassic = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + val fiat = mockCurrency(PrimaryDisplay.FIAT) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + primaryDisplay = btcClassic.primaryDisplay, + displayUnit = btcClassic.displayUnit, + ) + ) + ) + + viewModel.handleNumberPadInput("5", btcClassic) + val originalSats = viewModel.uiState.value.amountSats + + // Switch unit to fiat + viewModel.switchUnit(btcClassic) + val fiatDisplay = viewModel.uiState.value.displayText + val fiatSats = viewModel.uiState.value.amountSats + + // Switch unit back to bitcoin + viewModel.switchUnit(fiat) + val finalDisplay = viewModel.uiState.value.displayText + val finalSats = viewModel.uiState.value.amountSats + + assertEquals("Sats amount should be preserved", originalSats, fiatSats) + assertEquals("Sats amount should be preserved after round trip", originalSats, finalSats) + assertEquals("Display should return to original value after round trip", "500 000 000", finalDisplay) + assertNotEquals("Fiat conversion should not be $0.10 for substantial Bitcoin amount", "0.10", fiatDisplay) + assertTrue("Original amount should be substantial (5 BTC)", originalSats >= 500_000_000L) + } + + @Test + fun `switchUnit with decimal amounts preserves precision`() = test { + val btcClassic = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + primaryDisplay = btcClassic.primaryDisplay, + displayUnit = btcClassic.displayUnit, + ) + ) + ) + val currencyRepo = CurrencyRepo( + bgDispatcher = testDispatcher, + currencyService = currencyService, + settingsStore = settingsStore, + cacheStore = cacheStore, + enablePolling = false, + clock = clock, + ) + + // Enter precise Bitcoin amount: 0.00000092 BTC (92 sats) + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput(KEY_DECIMAL, btcClassic) + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput("9", btcClassic) + viewModel.handleNumberPadInput("2", btcClassic) + + val originalSats = viewModel.uiState.value.amountSats + val originalDisplay = viewModel.uiState.value.displayText + + // Switch to fiat and back + val btcState = currencyRepo.currencyState.value.copy(primaryDisplay = PrimaryDisplay.BITCOIN) + viewModel.switchUnit(btcState) // Bitcoin -> Fiat + val fiatState = currencyRepo.currencyState.value.copy(primaryDisplay = PrimaryDisplay.FIAT) + viewModel.switchUnit(fiatState) // Fiat -> Bitcoin + + val finalSats = viewModel.uiState.value.amountSats + val finalDisplay = viewModel.uiState.value.displayText + + // Precision should be maintained + assertEquals("Precise sats amount should be preserved", originalSats, finalSats) + assertEquals("Decimal precision should be preserved", originalDisplay, finalDisplay) + assertEquals("Should be exactly 92 sats", 92L, originalSats) + } + + @Test + fun `switchUnit handles empty and partial input safely`() = test { + val btcClassic = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + val fiat = mockCurrency(PrimaryDisplay.FIAT) + + // Test with empty input + viewModel.switchUnit(fiat) + assertEquals("Empty input should remain 0 sats", 0L, viewModel.uiState.value.amountSats) + assertEquals("Empty input should have empty display", "", viewModel.uiState.value.displayText) + + // Test with partial input "0." + viewModel.handleNumberPadInput("0", fiat) + viewModel.handleNumberPadInput(KEY_DECIMAL, fiat) + val partialSats = viewModel.uiState.value.amountSats + + // Toggle to Bitcoin and back + viewModel.switchUnit(btcClassic) + viewModel.switchUnit(fiat) + + // Should handle gracefully without crashes + assertEquals("Partial input sats should be preserved", partialSats, viewModel.uiState.value.amountSats) + + // Test toggling with just decimal point from Bitcoin side + viewModel.clearInput() + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput(KEY_DECIMAL, btcClassic) + + // Should not crash when toggling + try { + viewModel.switchUnit(fiat) + // If we get here without exception, test passes + assertTrue("Should not crash with partial Bitcoin input", true) + } catch (e: Exception) { + assertTrue("Should not crash with partial Bitcoin input, but got: ${e.message}", false) + } + } + + @Test + fun `switchUnit updates display text correctly`() = test { + val btcModern = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + val fiat = mockCurrency(PrimaryDisplay.FIAT) + + // Start with modern Bitcoin: 1000 sats + viewModel.handleNumberPadInput("1", btcModern) + viewModel.handleNumberPadInput("0", btcModern) + viewModel.handleNumberPadInput("0", btcModern) + viewModel.handleNumberPadInput("0", btcModern) + + val modernDisplay = viewModel.uiState.value.displayText + val satsAmount = viewModel.uiState.value.amountSats + + // Note: Since we're using NoopAmountHandler, we can't actually test currency conversion + // But we can test that switchUnit doesn't crash and preserves sats amount + viewModel.switchUnit(fiat) + val afterToggleSats = viewModel.uiState.value.amountSats + + // Verify display format for modern Bitcoin + assertEquals("Modern Bitcoin should show formatted sats", "1 000", modernDisplay) + assertEquals("Sats amount should remain constant after toggle", satsAmount, afterToggleSats) + assertTrue("Amount should be 1000 sats", satsAmount == 1000L) + + // Verify toggle doesn't crash (basic functionality test) + assertTrue("Toggle operation should complete without error", true) + } + + @Test + fun `classic conversion calculations are accurate`() = test { + val btcClassic = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + primaryDisplay = PrimaryDisplay.BITCOIN, + displayUnit = BitcoinDisplayUnit.CLASSIC + ) + ) + ) + + while (currencyRepo.currencyState.value.rates.isEmpty()) { + delay(1.milliseconds) + } + + // Test case 1: Use realistic amount that doesn't exceed MAX_AMOUNT + // Input "5" BTC (5 * 100,000,000 = 500,000,000 sats, which is under MAX_AMOUNT) + viewModel.handleNumberPadInput("5", btcClassic) + val largeBtcSats = viewModel.uiState.value.amountSats + + // Toggle from Bitcoin to Fiat - pass current Bitcoin state + val currentBtcState = currencyRepo.currencyState.value.copy(primaryDisplay = PrimaryDisplay.BITCOIN) + viewModel.switchUnit(currentBtcState) + val largeBtcFiatDisplay = viewModel.uiState.value.displayText + + // 5 BTC is a substantial amount and should not convert to tiny values like $0.10 + assertNotEquals("5 BTC should not convert to $0.10", "0.10", largeBtcFiatDisplay) + assertNotEquals("5 BTC should not convert to $0", "0", largeBtcFiatDisplay) + assertTrue("5 BTC should convert to substantial sats amount", largeBtcSats >= 500_000_000L) // 5 BTC in sats + + // Test case 2: Small amounts should convert correctly + viewModel.clearInput() + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput(KEY_DECIMAL, btcClassic) + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput("0", btcClassic) + viewModel.handleNumberPadInput("1", btcClassic) + val smallBtcSats = viewModel.uiState.value.amountSats + + // Toggle from Bitcoin to Fiat - pass current Bitcoin state + viewModel.switchUnit(currentBtcState) + val smallBtcFiatDisplay = viewModel.uiState.value.displayText + + // 0.001 BTC should convert to reasonable fiat amount (not 0 or extremely large) + assertTrue("Small BTC amount should have reasonable sats value", smallBtcSats > 0) + assertTrue( + "Small BTC should convert to reasonable fiat", + smallBtcFiatDisplay.isNotEmpty() && smallBtcFiatDisplay != "0" + ) + + // Test case 3: Verify conversion consistency + val fiatAmount = smallBtcFiatDisplay.replace(",", "").toDoubleOrNull() + assertNotNull("Fiat display should be parseable as number (got: '$smallBtcFiatDisplay')", fiatAmount) + if (fiatAmount != null) { + assertTrue("Converted fiat should be positive", fiatAmount > 0) + } + } +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 7d8756c49..d280b7f43 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -640,8 +640,8 @@ style: ignoreCompanionObjectPropertyDeclaration: true ignoreAnnotation: false ignoreNamedArgument: true - ignoreEnums: false - ignoreRanges: false + ignoreEnums: true + ignoreRanges: true ignoreExtensionFunctions: true MandatoryBracesLoops: active: false From fc3d19ef1b166788e8242f0b35235121ac0be9ca Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 12 Sep 2025 12:53:21 +0200 Subject: [PATCH 06/10] fix(receive): dont request liquidity for 0 LN channels --- .../java/to/bitkit/repositories/WalletRepo.kt | 25 +++---------------- .../to/bitkit/repositories/WalletRepoTest.kt | 20 ++++++++++++--- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index a3a3649ec..890b3f493 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.Event -import org.lightningdevkit.ldknode.Txid import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore @@ -25,6 +24,7 @@ import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.ext.filterOpen import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toHex import to.bitkit.ext.totalNextOutboundHtlcLimitSats @@ -422,6 +422,8 @@ class WalletRepo @Inject constructor( if (coreService.checkGeoStatus() == true) return@withContext Result.success(false) val channels = lightningRepo.lightningState.value.channels + if (channels.filterOpen().isEmpty()) return@withContext Result.success(false) + val inboundBalanceSats = channels.sumOf { it.inboundCapacityMsat / 1000u } Result.success((_walletState.value.bip21AmountSats ?: 0uL) >= inboundBalanceSats) @@ -461,27 +463,6 @@ class WalletRepo @Inject constructor( } } - suspend fun searchInvoiceByPaymentHash(paymentHash: String): Result = withContext(bgDispatcher) { - return@withContext try { - val invoiceTag = - db.tagMetadataDao().searchByPaymentHash(paymentHash = paymentHash) ?: return@withContext Result.failure( - Exception("Invoice not found") - ) - Result.success(invoiceTag) - } catch (e: Throwable) { - Logger.error("searchInvoice error", e, context = TAG) - Result.failure(e) - } - } - - suspend fun deleteInvoice(txId: Txid) = withContext(bgDispatcher) { - try { - db.tagMetadataDao().deleteByPaymentHash(paymentHash = txId) - } catch (e: Throwable) { - Logger.error("deleteInvoice error", e, context = TAG) - } - } - suspend fun deleteAllInvoices() = withContext(bgDispatcher) { try { db.tagMetadataDao().deleteAll() diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c227e3eee..ecf27da59 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -402,14 +402,16 @@ class WalletRepoTest : BaseUnitTest() { whenever(coreService.checkGeoStatus()).thenReturn(false) val testChannels = listOf( mock { - on { inboundCapacityMsat } doReturn 500_000u // 500 sats + on { inboundCapacityMsat } doReturn 500_000u + on { isChannelReady } doReturn true }, mock { - on { inboundCapacityMsat } doReturn 300_000u // 300 sats + on { inboundCapacityMsat } doReturn 300_000u + on { isChannelReady } doReturn true } ) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = testChannels))) - sut.updateBip21Invoice(amountSats = 1000uL) // 1000 sats + sut.updateBip21Invoice(amountSats = 1000uL) // When val result = sut.shouldRequestAdditionalLiquidity() @@ -419,6 +421,18 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(result.getOrThrow()) } + @Test + fun `should not request additional liquidity for 0 channels`() = test { + whenever(coreService.checkGeoStatus()).thenReturn(false) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + sut.updateBip21Invoice(amountSats = 1000uL) + + val result = sut.shouldRequestAdditionalLiquidity() + + assertTrue(result.isSuccess) + assertFalse(result.getOrThrow()) + } + @Test fun `shouldRequestAdditionalLiquidity should return false when amount is less than inbound capacity`() = test { // Given From 3bec3ff8d5d9fe088f57fe89c2e20738e99cb80e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 12 Sep 2025 19:43:02 +0200 Subject: [PATCH 07/10] chore: update detekt-baseline --- app/detekt-baseline.xml | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 1b66e4f1d..872ae710e 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -23,12 +23,10 @@ ComposableParamOrder:CalculatorCard.kt$CalculatorCard ComposableParamOrder:CalculatorCard.kt$CalculatorCardContent ComposableParamOrder:CalculatorPreviewScreen.kt$CalculatorPreviewScreen - ComposableParamOrder:EditInvoiceScreen.kt$EditInvoiceScreen ComposableParamOrder:FactsCard.kt$FactsCard ComposableParamOrder:HeadlineCard.kt$HeadlineCard ComposableParamOrder:HomeScreen.kt$Content ComposableParamOrder:InfoScreenContent.kt$InfoScreenContent - ComposableParamOrder:Keyboard.kt$KeyTextButton ComposableParamOrder:Money.kt$MoneyCaptionB ComposableParamOrder:NumberPadTextField.kt$MoneyAmount ComposableParamOrder:OnboardingSlidesScreen.kt$OnboardingSlidesScreen @@ -37,8 +35,6 @@ ComposableParamOrder:ReceiveConfirmScreen.kt$ReceiveConfirmScreen ComposableParamOrder:ReportIssueScreen.kt$ReportIssueScreen ComposableParamOrder:RestoreWalletScreen.kt$MnemonicInputField - ComposableParamOrder:SendAmountScreen.kt$SendAmountContent - ComposableParamOrder:SendAmountScreen.kt$SendAmountScreen ComposableParamOrder:SheetHost.kt$SheetHost ComposableParamOrder:SpendingAmountScreen.kt$SpendingAmountScreen ComposableParamOrder:SuggestionCard.kt$SuggestionCard @@ -89,8 +85,6 @@ CyclomaticComplexMethod:HomeScreen.kt$@Composable fun HomeScreen( mainUiState: MainUiState, drawerState: DrawerState, rootNavController: NavController, walletNavController: NavHostController, settingsViewModel: SettingsViewModel, walletViewModel: WalletViewModel, appViewModel: AppViewModel, activityListViewModel: ActivityListViewModel, homeViewModel: HomeViewModel = hiltViewModel(), ) CyclomaticComplexMethod:HomeScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable private fun Content( mainUiState: MainUiState, homeUiState: HomeUiState, rootNavController: NavController, walletNavController: NavController, drawerState: DrawerState, hazeState: HazeState = rememberHazeState(), latestActivities: List<Activity>?, onClickProfile: () -> Unit = {}, onRefresh: () -> Unit = {}, onRemoveSuggestion: (Suggestion) -> Unit = {}, onClickSuggestion: (Suggestion) -> Unit = {}, onClickAddWidget: () -> Unit = {}, onClickEditWidgetList: () -> Unit = {}, onClickEditWidget: (WidgetType) -> Unit = {}, onClickDeleteWidget: (WidgetType) -> Unit = {}, onMoveWidget: (Int, Int) -> Unit = { _, _ -> }, onDismissEmptyState: () -> Unit = {}, onDismissHighBalanceSheet: () -> Unit = {}, onClickEmptyActivityRow: () -> Unit = {}, balances: BalanceState = LocalBalances.current, ) CyclomaticComplexMethod:LightningService.kt$LightningService$private fun logEvent(event: Event) - CyclomaticComplexMethod:NumberPadTextField.kt$@Composable fun AmountInputHandler( input: String, primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit, onInputChanged: (String) -> Unit, onAmountCalculated: (String) -> Unit, currencyVM: CurrencyViewModel = hiltViewModel(), overrideSats: Long? = null, ) - CyclomaticComplexMethod:NumberPadTextField.kt$@Composable fun NumberPadTextField( input: String, displayUnit: BitcoinDisplayUnit, primaryDisplay: PrimaryDisplay, modifier: Modifier = Modifier, showSecondaryField: Boolean = true, ) CyclomaticComplexMethod:ReceiveQrScreen.kt$@Composable fun ReceiveQrScreen( cjitInvoice: MutableState<String?>, cjitActive: MutableState<Boolean>, walletState: MainUiState, onCjitToggle: (Boolean) -> Unit, onClickEditInvoice: () -> Unit, onClickReceiveOnSpending: () -> Unit, modifier: Modifier = Modifier, ) CyclomaticComplexMethod:RestoreWalletScreen.kt$@Composable fun RestoreWalletView( onBackClick: () -> Unit, onRestoreClick: (mnemonic: String, passphrase: String?) -> Unit, ) CyclomaticComplexMethod:SendSheet.kt$@Composable fun SendSheet( appViewModel: AppViewModel, walletViewModel: WalletViewModel, startDestination: SendRoute = SendRoute.Recipient, ) @@ -128,7 +122,6 @@ ForbiddenComment:Notifications.kt$// TODO: review if needed: ForbiddenComment:SuccessScreen.kt$// TODO: verify backup ForbiddenComment:TransferViewModel.kt$TransferViewModel$// TODO: showBottomSheet: forceTransfer - FunctionOnlyReturningConstant:RepoModule.kt$RepoModule$@Provides @Named("enablePolling") fun provideEnablePolling(): Boolean FunctionOnlyReturningConstant:ShopWebViewInterface.kt$ShopWebViewInterface$@JavascriptInterface fun isReady(): Boolean ImplicitDefaultLocale:BlocksService.kt$BlocksService$String.format("%.2f", blockInfo.difficulty / 1_000_000_000_000.0) ImplicitDefaultLocale:PriceService.kt$PriceService$String.format("%.2f", price) @@ -152,8 +145,6 @@ LambdaParameterInRestartableEffect:EditInvoiceScreen.kt$updateInvoice LambdaParameterInRestartableEffect:InitializingWalletView.kt$onComplete LambdaParameterInRestartableEffect:LnurlChannelScreen.kt$onConnected - LambdaParameterInRestartableEffect:NumberPadTextField.kt$onAmountCalculated - LambdaParameterInRestartableEffect:NumberPadTextField.kt$onInputChanged LambdaParameterInRestartableEffect:PinConfirmScreen.kt$onPinConfirmed LambdaParameterInRestartableEffect:PricePreviewScreen.kt$onClose LambdaParameterInRestartableEffect:QrCodeImage.kt$onBitmapGenerated @@ -234,8 +225,6 @@ MagicNumber:BackupNavSheetViewModel.kt$BackupNavSheetViewModel$200 MagicNumber:BackupRepo.kt$BackupRepo$60000 MagicNumber:BackupSettingsScreen.kt$1000 - MagicNumber:BackupSettingsScreen.kt$35 - MagicNumber:BackupSettingsScreen.kt$5 MagicNumber:BackupSettingsScreen.kt$60 MagicNumber:BackupsViewModel.kt$BackupsViewModel$500 MagicNumber:BiometricCrypto.kt$BiometricCrypto$256 @@ -269,15 +258,10 @@ MagicNumber:ContentView.kt$100 MagicNumber:ContentView.kt$500 MagicNumber:Context.kt$1024 - MagicNumber:CoreService.kt$ActivityService$10 - MagicNumber:CoreService.kt$ActivityService$1000 - MagicNumber:CoreService.kt$ActivityService$1_000_000 MagicNumber:CoreService.kt$ActivityService$24 - MagicNumber:CoreService.kt$ActivityService$3 MagicNumber:CoreService.kt$ActivityService$30L MagicNumber:CoreService.kt$ActivityService$60 MagicNumber:CoreService.kt$ActivityService$64 - MagicNumber:CoreService.kt$ActivityService$7 MagicNumber:CoreService.kt$ActivityService$8 MagicNumber:Crypto.kt$Crypto$128 MagicNumber:Crypto.kt$Crypto$16 @@ -296,7 +280,6 @@ MagicNumber:HttpModule.kt$HttpModule$60_000 MagicNumber:InitializingWalletView.kt$500 MagicNumber:InitializingWalletView.kt$99.9 - MagicNumber:Keyboard.kt$0.2f MagicNumber:LightningChannel.kt$0.5f MagicNumber:LightningConnectionsViewModel.kt$LightningConnectionsViewModel$10 MagicNumber:LightningConnectionsViewModel.kt$LightningConnectionsViewModel$500 @@ -526,7 +509,7 @@ ModifierMissing:WeatherEditScreen.kt$WeatherEditContent ModifierMissing:WeatherPreviewScreen.kt$WeatherPreviewContent ModifierMissing:WidgetsIntroScreen.kt$WidgetsIntroScreen - ModifierNotUsedAtRoot:AmountInput.kt$modifier = modifier.clickableAlpha { currency.togglePrimaryDisplay() } + ModifierNotUsedAtRoot:AmountInput.kt$modifier = modifier.clickableAlpha { currency.switchUnit() } ModifierNotUsedAtRoot:SettingsTextButtonRow.kt$modifier = modifier.then(if (!enabled) Modifier.alpha(0.5f) else Modifier) ModifierWithoutDefault:ReceiveQrScreen.kt$modifier ModifierWithoutDefault:SyncNodeView.kt$modifier @@ -551,22 +534,17 @@ ParameterNaming:AddressViewerScreen.kt$onSearchTextChanged ParameterNaming:BiometricPrompt.kt$onFailed ParameterNaming:BiometricPrompt.kt$onUnsupported - ParameterNaming:EditInvoiceScreen.kt$onInputChanged - ParameterNaming:EditInvoiceScreen.kt$onInputUpdated ParameterNaming:EditInvoiceScreen.kt$onTextChanged ParameterNaming:ExternalConnectionScreen.kt$onNodeConnected ParameterNaming:FundingScreen.kt$onAdvanced ParameterNaming:LnurlChannelScreen.kt$onConnected ParameterNaming:LocationBlockScreen.kt$onBackPressed - ParameterNaming:NumberPadTextField.kt$onAmountCalculated - ParameterNaming:NumberPadTextField.kt$onInputChanged ParameterNaming:PinChooseScreen.kt$onPinChosen ParameterNaming:PinConfirmScreen.kt$onPinConfirmed ParameterNaming:QrCodeImage.kt$onBitmapGenerated ParameterNaming:ReceiveAmountScreen.kt$onCjitCreated ParameterNaming:RestoreWalletScreen.kt$onPositionChanged ParameterNaming:RestoreWalletScreen.kt$onValueChanged - ParameterNaming:SendAmountScreen.kt$onInputChanged ParameterNaming:SpendingAdvancedScreen.kt$onOrderCreated ParameterNaming:SpendingAmountScreen.kt$onOrderCreated ParameterNaming:TransactionSpeedSettingsScreen.kt$onSpeedSelected @@ -653,7 +631,6 @@ TooManyFunctions:ContentView.kt$to.bitkit.ui.ContentView.kt TooManyFunctions:CoreService.kt$ActivityService TooManyFunctions:CoreService.kt$BlocktankService - TooManyFunctions:CurrencyRepo.kt$CurrencyRepo TooManyFunctions:DevSettingsViewModel.kt$DevSettingsViewModel : ViewModel TooManyFunctions:ElectrumConfigViewModel.kt$ElectrumConfigViewModel : ViewModel TooManyFunctions:ExternalNodeViewModel.kt$ExternalNodeViewModel : ViewModel @@ -663,14 +640,12 @@ TooManyFunctions:LightningService.kt$LightningService : BaseCoroutineScope TooManyFunctions:Logger.kt$Logger TooManyFunctions:NodeInfoScreen.kt$to.bitkit.ui.NodeInfoScreen.kt - TooManyFunctions:NumberPadTextField.kt$to.bitkit.ui.components.NumberPadTextField.kt TooManyFunctions:SendAmountScreen.kt$to.bitkit.ui.screens.wallets.send.SendAmountScreen.kt TooManyFunctions:SendConfirmScreen.kt$to.bitkit.ui.screens.wallets.send.SendConfirmScreen.kt TooManyFunctions:SettingsViewModel.kt$SettingsViewModel : ViewModel TooManyFunctions:TOS.kt$to.bitkit.ui.onboarding.TOS.kt TooManyFunctions:TagMetadataDao.kt$TagMetadataDao TooManyFunctions:Text.kt$to.bitkit.ui.components.Text.kt - TooManyFunctions:Text.kt$to.bitkit.ui.utils.Text.kt TooManyFunctions:TransferViewModel.kt$TransferViewModel : ViewModel TooManyFunctions:WalletRepo.kt$WalletRepo TooManyFunctions:WalletViewModel.kt$WalletViewModel : ViewModel @@ -680,7 +655,6 @@ TopLevelPropertyNaming:DrawerMenu.kt$private const val zIndexScrim = 10f UnusedPrivateProperty:ActivityRepoTest.kt$ActivityRepoTest$private val testOnChainActivity = mock<Activity.Onchain> { on { v1 } doReturn testOnChainActivityV1 } UnusedPrivateProperty:CurrencyRepoTest.kt$CurrencyRepoTest$private val toastEventBus: ToastEventBus = mock() - UseCheckOrError:CurrencyRepo.kt$CurrencyRepo$throw IllegalStateException( "Rate not found for currency: $targetCurrency. Available currencies: ${ _currencyState.value.rates.joinToString { it.quote } }" ) ViewModelForwarding:ActivityDetailScreen.kt$ActivityAddTagSheet( listViewModel = listViewModel, activityViewModel = detailViewModel, onDismiss = { showAddTagSheet = false }, ) ViewModelForwarding:ContentView.kt$BackupSheet(sheet, appViewModel) ViewModelForwarding:ContentView.kt$LnurlAuthSheet(sheet, appViewModel) From 68ec4825a269238b258c037fab05ab749768b31b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 12 Sep 2025 23:42:16 +0200 Subject: [PATCH 08/10] chore: merge fixes --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fa13fa17c..3bc2fb9e9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -341,7 +341,7 @@ class AppViewModel @Inject constructor( } } - private fun onAmountChange(amount: ULong) { + private suspend fun onAmountChange(amount: ULong) { _sendUiState.update { it.copy( amount = amount, @@ -395,7 +395,7 @@ class AppViewModel @Inject constructor( } } - private fun onPaymentMethodSwitch() { + private suspend fun onPaymentMethodSwitch() { val nextPaymentMethod = when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> SendMethod.LIGHTNING SendMethod.LIGHTNING -> SendMethod.ONCHAIN @@ -442,7 +442,7 @@ class AppViewModel @Inject constructor( setSendEffect(SendEffect.NavigateToConfirm) } - private fun validateAmount( + private suspend fun validateAmount( amount: ULong, payMethod: SendMethod = _sendUiState.value.payMethod, ): Boolean { From b66c2c9bbd4539128278520ab4c9fe339a93dc74 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 13 Sep 2025 01:48:49 +0200 Subject: [PATCH 09/10] chore: post merge fixes --- app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 42896ac2c..fba47357f 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -423,7 +423,7 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `should not request additional liquidity for 0 channels`() = test { - whenever(coreService.checkGeoStatus()).thenReturn(false) + whenever(coreService.checkGeoBlock()).thenReturn(Pair(false, false)) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) sut.updateBip21Invoice(amountSats = 1000uL) From 656b829de150e419bf0c79258edbc354edc8a9d0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 13 Sep 2025 02:50:31 +0200 Subject: [PATCH 10/10] chore: cleanup after self review --- .../to/bitkit/repositories/CurrencyRepo.kt | 9 +- .../java/to/bitkit/ui/components/Money.kt | 1 - .../ui/components/NumberPadTextField.kt | 4 +- .../ui/screens/transfer/FundingScreen.kt | 2 +- .../screens/transfer/SpendingAmountScreen.kt | 4 +- .../wallets/receive/EditInvoiceScreen.kt | 4 +- .../wallets/receive/ReceiveAmountScreen.kt | 4 +- .../screens/wallets/send/SendAmountScreen.kt | 4 +- .../bitkit/viewmodels/AmountInputViewModel.kt | 46 ++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../viewmodels/AmountInputViewModelTest.kt | 232 +++++++++--------- 11 files changed, 153 insertions(+), 158 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 8de9d3458..a8629a23b 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -156,7 +156,7 @@ class CurrencyRepo @Inject constructor( } suspend fun switchUnit() = withContext(bgDispatcher) { - settingsStore.update { it.copy(primaryDisplay = _currencyState.value.primaryDisplay.not()) } + settingsStore.update { it.copy(primaryDisplay = it.primaryDisplay.not()) } } override suspend fun switchUnit(unit: PrimaryDisplay): PrimaryDisplay = withContext(bgDispatcher) { @@ -265,17 +265,12 @@ interface AmountInputHandler { companion object { fun stub(state: CurrencyState = CurrencyState()) = object : AmountInputHandler { - private var currentPrimaryDisplay = state.primaryDisplay - override fun convertSatsToFiatString(sats: Long): String { return sats.asBtc().multiply(BigDecimal.valueOf(STUB_RATE)).formatCurrency() ?: "" } override fun convertFiatToSats(fiat: Double) = (fiat / STUB_RATE * SATS_IN_BTC).toLong() - override suspend fun switchUnit(unit: PrimaryDisplay): PrimaryDisplay { - currentPrimaryDisplay = currentPrimaryDisplay.not() - return currentPrimaryDisplay - } + override suspend fun switchUnit(unit: PrimaryDisplay) = unit.not() } } } 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 ec2659c94..d0d012e98 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -125,7 +125,6 @@ fun rememberMoneyText( if (unit == PrimaryDisplay.BITCOIN) { append(sats.formatToModernDisplay()) } else { - // For fiat preview, convert sats to fiat using STUB_RATE and formatCurrency val fiatValue = sats.asBtc().multiply(BigDecimal.valueOf(STUB_RATE)) append(fiatValue.formatCurrency() ?: "0.00") } diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt index 46d921247..7f1cb0257 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt @@ -42,11 +42,11 @@ fun NumberPadTextField( ) { MoneyAmount( modifier = modifier.then(Modifier.clickableAlpha(onClick = onClick)), - value = uiState.value.displayText, + value = uiState.value.text, unit = currencies.primaryDisplay, placeholder = viewModel.getPlaceholder(currencies), showPlaceholder = true, - satoshis = uiState.value.amountSats, + satoshis = uiState.value.sats, currencySymbol = currencies.currencySymbol, showSecondaryField = showSecondaryField, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt index 4a55174bc..0c181d625 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt @@ -41,12 +41,12 @@ import to.bitkit.ui.utils.withAccent @Composable fun FundingScreen( + isGeoBlocked: Boolean, onTransfer: () -> Unit = {}, onFund: () -> Unit = {}, onAdvanced: () -> Unit = {}, onBackClick: () -> Unit = {}, onCloseClick: () -> Unit = {}, - isGeoBlocked: Boolean ) { val balances = LocalBalances.current val canTransfer = remember(balances.totalOnchainSats) { diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index 7552bf002..a81ae3cbe 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -108,7 +108,7 @@ fun SpendingAmountScreen( viewModel.updateLimits(newAmountSats) amountInputViewModel.setSats(newAmountSats, currencies) }, - onConfirmAmount = { viewModel.onConfirmAmount(amountUiState.amountSats) }, + onConfirmAmount = { viewModel.onConfirmAmount(amountUiState.sats) }, ) } @@ -238,7 +238,7 @@ private fun SpendingAmountNodeRunning( PrimaryButton( text = stringResource(R.string.common__continue), onClick = onConfirmAmount, - enabled = amountUiState.amountSats != 0L && amountUiState.amountSats <= uiState.maxAllowedToSend, + enabled = amountUiState.sats != 0L && amountUiState.sats <= uiState.maxAllowedToSend, isLoading = uiState.isLoading, modifier = Modifier.testTag("SpendingAmountContinue") ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 3c66058f0..e8dd7d05f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -88,7 +88,7 @@ fun EditInvoiceScreen( LaunchedEffect(Unit) { editInvoiceVM.editInvoiceEffect.collect { effect -> - val receiveSats = amountInputUiState.amountSats.toULong() + val receiveSats = amountInputUiState.sats.toULong() when (effect) { is EditInvoiceVM.EditInvoiceScreenEffects.NavigateAddLiquidity -> { updateInvoice(receiveSats) @@ -140,7 +140,7 @@ fun EditInvoiceScreen( }, onContinueKeyboard = { keyboardVisible = false }, onContinueGeneral = { - updateInvoice(amountInputUiState.amountSats.toULong()) + updateInvoice(amountInputUiState.sats.toULong()) editInvoiceVM.onClickContinue() }, onClickAddTag = onClickAddTag, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt index ab27e300d..c73b30395 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt @@ -82,11 +82,11 @@ fun ReceiveAmountScreen( minCjitSats = minCjitSats, currencies = currencies, isCreatingInvoice = isCreatingInvoice, - canContinue = amountInputUiState.amountSats >= (minCjitSats?.toLong() ?: 0), + canContinue = amountInputUiState.sats >= (minCjitSats?.toLong() ?: 0), onBack = onBack, onClickMin = { amountInputViewModel.setSats(it, currencies) }, onContinue = { - val sats = amountInputUiState.amountSats + val sats = amountInputUiState.sats scope.launch { isCreatingInvoice = true runCatching { 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 b88dc400c..cd2e8a26c 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 @@ -87,8 +87,8 @@ fun SendAmountScreen( } } - LaunchedEffect(amountInputUiState.amountSats) { - currentOnEvent(SendEvent.AmountChange(amountInputUiState.amountSats.toULong())) + LaunchedEffect(amountInputUiState.sats) { + currentOnEvent(SendEvent.AmountChange(amountInputUiState.sats.toULong())) } SendAmountContent( diff --git a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt index 0bafe7c9d..08c49167a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt @@ -77,8 +77,8 @@ class AmountInputViewModel @Inject constructor( rawInputText = newText _uiState.update { it.copy( - displayText = formatDisplayTextFromAmount(newAmount, primaryDisplay, isModern = true), - amountSats = newAmount, + text = formatDisplayTextFromAmount(newAmount, primaryDisplay, isModern = true), + sats = newAmount, errorKey = null ) } @@ -95,12 +95,12 @@ class AmountInputViewModel @Inject constructor( rawInputText = newText _uiState.update { it.copy( - displayText = if (primaryDisplay == PrimaryDisplay.FIAT) { + text = if (primaryDisplay == PrimaryDisplay.FIAT) { formatFiatGroupingOnly(newText) } else { newText }, - amountSats = newAmount, + sats = newAmount, errorKey = null ) } @@ -113,8 +113,8 @@ class AmountInputViewModel @Inject constructor( rawInputText = newText _uiState.update { it.copy( - amountSats = 0, - displayText = "", + sats = 0, + text = "", errorKey = null ) } @@ -128,14 +128,14 @@ class AmountInputViewModel @Inject constructor( _uiState.update { it.copy( - amountSats = sats, - displayText = formatDisplayTextFromAmount(sats, primaryDisplay, isModern) + sats = sats, + text = formatDisplayTextFromAmount(sats, primaryDisplay, isModern) ) } // Update raw input text based on the formatted display rawInputText = when (primaryDisplay) { - PrimaryDisplay.FIAT -> _uiState.value.displayText.replace(",", "") - else -> _uiState.value.displayText + PrimaryDisplay.FIAT -> _uiState.value.text.replace(",", "") + else -> _uiState.value.text } } @@ -149,17 +149,17 @@ class AmountInputViewModel @Inject constructor( val newPrimaryDisplay = amountInputHandler.switchUnit(currencies.primaryDisplay) // Update display text when currency changes - val amountSats = _uiState.value.amountSats + val amountSats = _uiState.value.sats if (amountSats > 0) { _uiState.update { it.copy( - displayText = formatDisplayTextFromAmount(amountSats, newPrimaryDisplay, isModern) + text = formatDisplayTextFromAmount(amountSats, newPrimaryDisplay, isModern) ) } // Update raw input text based on the new display rawInputText = when (newPrimaryDisplay) { - PrimaryDisplay.FIAT -> _uiState.value.displayText.replace(",", "") - else -> _uiState.value.displayText + PrimaryDisplay.FIAT -> _uiState.value.text.replace(",", "") + else -> _uiState.value.text } } else if (currentRawInput.isNotEmpty()) { // Convert the raw input from the old currency to the new currency @@ -170,7 +170,7 @@ class AmountInputViewModel @Inject constructor( val converted = amountInputHandler.convertSatsToFiatString(sats) if (converted.isNotEmpty()) { rawInputText = converted.replace(",", "") - _uiState.update { it.copy(displayText = formatFiatGroupingOnly(rawInputText)) } + _uiState.update { it.copy(text = formatFiatGroupingOnly(rawInputText)) } } } @@ -179,7 +179,7 @@ class AmountInputViewModel @Inject constructor( val sats = convertFiatToSats(currentRawInput) if (sats != null) { rawInputText = formatBitcoinFromSats(sats, isModern) - _uiState.update { it.copy(displayText = rawInputText) } + _uiState.update { it.copy(text = rawInputText) } } } } @@ -212,7 +212,7 @@ class AmountInputViewModel @Inject constructor( fun getPlaceholder(currencyState: CurrencyState): String { val primaryDisplay = currencyState.primaryDisplay val isModern = currencyState.displayUnit.isModern() - if (_uiState.value.displayText.isEmpty()) { + if (_uiState.value.text.isEmpty()) { return when (primaryDisplay) { PrimaryDisplay.BITCOIN -> if (isModern) PLACEHOLDER_MODERN else PLACEHOLDER_CLASSIC PrimaryDisplay.FIAT -> PLACEHOLDER_FIAT @@ -223,8 +223,8 @@ class AmountInputViewModel @Inject constructor( if (isModern) { PLACEHOLDER_MODERN_DECIMALS } else { - if (_uiState.value.displayText.contains(".")) { - val parts = _uiState.value.displayText.split(".", limit = 2) + if (_uiState.value.text.contains(".")) { + val parts = _uiState.value.text.split(".", limit = 2) val decimalPart = if (parts.size > 1) parts[1] else "" val remainingDecimals = CLASSIC_DECIMALS - decimalPart.length if (remainingDecimals > 0) "0".repeat(remainingDecimals) else "" @@ -235,8 +235,8 @@ class AmountInputViewModel @Inject constructor( } PrimaryDisplay.FIAT -> { - if (_uiState.value.displayText.contains(".")) { - val parts = _uiState.value.displayText.split(".", limit = 2) + if (_uiState.value.text.contains(".")) { + val parts = _uiState.value.text.split(".", limit = 2) val decimalPart = if (parts.size > 1) parts[1] else "" val remainingDecimals = FIAT_DECIMALS - decimalPart.length if (remainingDecimals > 0) "0".repeat(remainingDecimals) else "" @@ -406,8 +406,8 @@ class AmountInputViewModel @Inject constructor( } data class AmountInputUiState( - val amountSats: Long = 0L, - val displayText: String = "", + val sats: Long = 0L, + val text: String = "", val errorKey: String? = null, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3bc2fb9e9..82c024051 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -87,6 +87,7 @@ import to.bitkit.utils.Logger import java.math.BigDecimal import javax.inject.Inject +@Suppress("LongParameterList") @HiltViewModel class AppViewModel @Inject constructor( @ApplicationContext private val context: Context, diff --git a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt index 6c35c968c..2c8bac799 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt @@ -89,20 +89,20 @@ class AmountInputViewModelTest : BaseUnitTest() { val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) viewModel.handleNumberPadInput("1", currency) - assertEquals("1", viewModel.uiState.value.displayText) - assertEquals(1L, viewModel.uiState.value.amountSats) + assertEquals("1", viewModel.uiState.value.text) + assertEquals(1L, viewModel.uiState.value.sats) viewModel.handleNumberPadInput("0", currency) - assertEquals("10", viewModel.uiState.value.displayText) - assertEquals(10L, viewModel.uiState.value.amountSats) + assertEquals("10", viewModel.uiState.value.text) + assertEquals(10L, viewModel.uiState.value.sats) viewModel.handleNumberPadInput("0", currency) - assertEquals("100", viewModel.uiState.value.displayText) - assertEquals(100L, viewModel.uiState.value.amountSats) + assertEquals("100", viewModel.uiState.value.text) + assertEquals(100L, viewModel.uiState.value.sats) viewModel.handleNumberPadInput("0", currency) - assertEquals("1 000", viewModel.uiState.value.displayText) - assertEquals(1000L, viewModel.uiState.value.amountSats) + assertEquals("1 000", viewModel.uiState.value.text) + assertEquals(1000L, viewModel.uiState.value.sats) } @Test @@ -113,11 +113,11 @@ class AmountInputViewModelTest : BaseUnitTest() { "999999999".forEach { digit -> viewModel.handleNumberPadInput(digit.toString(), currency) } - assertEquals(AmountInputViewModel.MAX_AMOUNT, viewModel.uiState.value.amountSats) + assertEquals(AmountInputViewModel.MAX_AMOUNT, viewModel.uiState.value.sats) // Try to exceed max amount - should be blocked viewModel.handleNumberPadInput("0", currency) - assertEquals(AmountInputViewModel.MAX_AMOUNT, viewModel.uiState.value.amountSats) + assertEquals(AmountInputViewModel.MAX_AMOUNT, viewModel.uiState.value.sats) assertNotNull(viewModel.uiState.value.errorKey) } @@ -133,8 +133,8 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("1", currency) viewModel.handleNumberPadInput(KEY_000, currency) - assertEquals("1 000", viewModel.uiState.value.displayText) - assertEquals(1000L, viewModel.uiState.value.amountSats) + assertEquals("1 000", viewModel.uiState.value.text) + assertEquals(1000L, viewModel.uiState.value.sats) } // MARK: - Classic Bitcoin Tests @@ -144,16 +144,16 @@ class AmountInputViewModelTest : BaseUnitTest() { val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) viewModel.handleNumberPadInput("1", currency) - assertEquals("1", viewModel.uiState.value.displayText) + assertEquals("1", viewModel.uiState.value.text) viewModel.handleNumberPadInput(KEY_DECIMAL, currency) - assertEquals("1.", viewModel.uiState.value.displayText) + assertEquals("1.", viewModel.uiState.value.text) viewModel.handleNumberPadInput("0", currency) - assertEquals("1.0", viewModel.uiState.value.displayText) + assertEquals("1.0", viewModel.uiState.value.text) viewModel.handleNumberPadInput("0", currency) - assertEquals("1.00", viewModel.uiState.value.displayText) + assertEquals("1.00", viewModel.uiState.value.text) } @Test @@ -167,11 +167,11 @@ class AmountInputViewModelTest : BaseUnitTest() { repeat(8) { viewModel.handleNumberPadInput("0", currency) } - assertEquals("1.00000000", viewModel.uiState.value.displayText) + assertEquals("1.00000000", viewModel.uiState.value.text) // Try to add more decimals - should be blocked viewModel.handleNumberPadInput("0", currency) - assertEquals("1.00000000", viewModel.uiState.value.displayText) // Should not change + assertEquals("1.00000000", viewModel.uiState.value.text) // Should not change } @Test @@ -183,7 +183,7 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("0", currency) // Should not allow "10" because 10 BTC > 999,999,999 sats - assertTrue(viewModel.uiState.value.amountSats <= AmountInputViewModel.MAX_AMOUNT) + assertTrue(viewModel.uiState.value.sats <= AmountInputViewModel.MAX_AMOUNT) } @Test @@ -213,7 +213,7 @@ class AmountInputViewModelTest : BaseUnitTest() { val currency = mockCurrency(PrimaryDisplay.FIAT) viewModel.handleNumberPadInput(KEY_DECIMAL, currency) - assertEquals("0.", viewModel.uiState.value.displayText) + assertEquals("0.", viewModel.uiState.value.text) } @Test @@ -222,10 +222,10 @@ class AmountInputViewModelTest : BaseUnitTest() { // Start with 0, then type a digit - should replace 0 viewModel.handleNumberPadInput("0", currency) - assertEquals("", viewModel.uiState.value.displayText) // Modern Bitcoin shows empty for 0 + assertEquals("", viewModel.uiState.value.text) // Modern Bitcoin shows empty for 0 viewModel.handleNumberPadInput("1", currency) - assertEquals("1", viewModel.uiState.value.displayText) + assertEquals("1", viewModel.uiState.value.text) } @Test @@ -234,10 +234,10 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("0", currency) viewModel.handleNumberPadInput(KEY_DECIMAL, currency) - assertEquals("0.", viewModel.uiState.value.displayText) + assertEquals("0.", viewModel.uiState.value.text) viewModel.handleNumberPadInput(KEY_DELETE, currency) - assertEquals("", viewModel.uiState.value.displayText) + assertEquals("", viewModel.uiState.value.text) } @Test @@ -249,20 +249,20 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput(KEY_DECIMAL, currency) viewModel.handleNumberPadInput("5", currency) viewModel.handleNumberPadInput("0", currency) - assertEquals("1.50", viewModel.uiState.value.displayText) + assertEquals("1.50", viewModel.uiState.value.text) // Delete back to "1." viewModel.handleNumberPadInput(KEY_DELETE, currency) - assertEquals("1.5", viewModel.uiState.value.displayText) + assertEquals("1.5", viewModel.uiState.value.text) viewModel.handleNumberPadInput(KEY_DELETE, currency) - assertEquals("1.", viewModel.uiState.value.displayText) + assertEquals("1.", viewModel.uiState.value.text) viewModel.handleNumberPadInput(KEY_DELETE, currency) - assertEquals("1", viewModel.uiState.value.displayText) + assertEquals("1", viewModel.uiState.value.text) viewModel.handleNumberPadInput(KEY_DELETE, currency) - assertEquals("", viewModel.uiState.value.displayText) + assertEquals("", viewModel.uiState.value.text) } @Test @@ -272,11 +272,11 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("1", currency) viewModel.handleNumberPadInput(KEY_DECIMAL, currency) viewModel.handleNumberPadInput("5", currency) - assertEquals("1.5", viewModel.uiState.value.displayText) + assertEquals("1.5", viewModel.uiState.value.text) // Try to add another decimal point viewModel.handleNumberPadInput(KEY_DECIMAL, currency) - assertEquals("1.5", viewModel.uiState.value.displayText) // Should not change + assertEquals("1.5", viewModel.uiState.value.text) // Should not change } @Test @@ -339,13 +339,13 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("0", currency) viewModel.handleNumberPadInput("0", currency) - assertEquals("100", viewModel.uiState.value.displayText) - assertEquals(100L, viewModel.uiState.value.amountSats) + assertEquals("100", viewModel.uiState.value.text) + assertEquals(100L, viewModel.uiState.value.sats) viewModel.clearInput() - assertEquals("", viewModel.uiState.value.displayText) - assertEquals(0L, viewModel.uiState.value.amountSats) + assertEquals("", viewModel.uiState.value.text) + assertEquals(0L, viewModel.uiState.value.sats) assertNull(viewModel.uiState.value.errorKey) } @@ -354,8 +354,8 @@ class AmountInputViewModelTest : BaseUnitTest() { val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) viewModel.setSats(12345L, currency) - assertEquals(12345L, viewModel.uiState.value.amountSats) - assertEquals("12 345", viewModel.uiState.value.displayText) + assertEquals(12345L, viewModel.uiState.value.sats) + assertEquals("12 345", viewModel.uiState.value.text) } @Test @@ -364,11 +364,11 @@ class AmountInputViewModelTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(flowOf(SettingsData(primaryDisplay = PrimaryDisplay.FIAT))) viewModel.setSats(100000L, currency) - assertEquals(100000L, viewModel.uiState.value.amountSats) - assertEquals("115.15", viewModel.uiState.value.displayText) + assertEquals(100000L, viewModel.uiState.value.sats) + assertEquals("115.15", viewModel.uiState.value.text) viewModel.switchUnit(currency) - assertEquals("100 000", viewModel.uiState.value.displayText) + assertEquals("100 000", viewModel.uiState.value.text) } @Test @@ -376,16 +376,16 @@ class AmountInputViewModelTest : BaseUnitTest() { val currency = mockCurrency(PrimaryDisplay.FIAT) viewModel.handleNumberPadInput("1", currency) - assertEquals("1", viewModel.uiState.value.displayText) + assertEquals("1", viewModel.uiState.value.text) viewModel.handleNumberPadInput("0", currency) - assertEquals("10", viewModel.uiState.value.displayText) + assertEquals("10", viewModel.uiState.value.text) viewModel.handleNumberPadInput("0", currency) - assertEquals("100", viewModel.uiState.value.displayText) + assertEquals("100", viewModel.uiState.value.text) viewModel.handleNumberPadInput("0", currency) - assertEquals("1,000", viewModel.uiState.value.displayText) + assertEquals("1,000", viewModel.uiState.value.text) } @Test @@ -397,22 +397,22 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("0", currency) viewModel.handleNumberPadInput("0", currency) viewModel.handleNumberPadInput("0", currency) - assertEquals("1,000", viewModel.uiState.value.displayText) + assertEquals("1,000", viewModel.uiState.value.text) viewModel.handleNumberPadInput(KEY_DECIMAL, currency) viewModel.handleNumberPadInput("0", currency) viewModel.handleNumberPadInput("0", currency) - assertEquals("1,000.00", viewModel.uiState.value.displayText) + assertEquals("1,000.00", viewModel.uiState.value.text) // Delete character by character viewModel.handleNumberPadInput(KEY_DELETE, currency) - assertEquals("1,000.0", viewModel.uiState.value.displayText) + assertEquals("1,000.0", viewModel.uiState.value.text) viewModel.handleNumberPadInput(KEY_DELETE, currency) - assertEquals("1,000.", viewModel.uiState.value.displayText) + assertEquals("1,000.", viewModel.uiState.value.text) viewModel.handleNumberPadInput(KEY_DELETE, currency) - assertEquals("1,000", viewModel.uiState.value.displayText) + assertEquals("1,000", viewModel.uiState.value.text) } @Test @@ -424,7 +424,7 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("0", currency) viewModel.handleNumberPadInput("0", currency) viewModel.handleNumberPadInput("1", currency) - assertEquals("1", viewModel.uiState.value.displayText) + assertEquals("1", viewModel.uiState.value.text) } @Test @@ -432,13 +432,13 @@ class AmountInputViewModelTest : BaseUnitTest() { // Test for fiat val fiatCurrency = mockCurrency(PrimaryDisplay.FIAT) viewModel.handleNumberPadInput(KEY_DECIMAL, fiatCurrency) - assertEquals("0.", viewModel.uiState.value.displayText) + assertEquals("0.", viewModel.uiState.value.text) // Clear and test for classic Bitcoin viewModel.clearInput() val classicBtc = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) viewModel.handleNumberPadInput(KEY_DECIMAL, classicBtc) - assertEquals("0.", viewModel.uiState.value.displayText) + assertEquals("0.", viewModel.uiState.value.text) } @Test @@ -531,15 +531,15 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("2", currency) viewModel.handleNumberPadInput("1", currency) - val initialDisplay = viewModel.uiState.value.displayText - val initialSats = viewModel.uiState.value.amountSats + val initialDisplay = viewModel.uiState.value.text + val initialSats = viewModel.uiState.value.sats // Try to add another digit - should be blocked viewModel.handleNumberPadInput("5", currency) // Both display and sats should remain unchanged - assertEquals(initialDisplay, viewModel.uiState.value.displayText) - assertEquals(initialSats, viewModel.uiState.value.amountSats) + assertEquals(initialDisplay, viewModel.uiState.value.text) + assertEquals(initialSats, viewModel.uiState.value.sats) } @Test @@ -553,15 +553,15 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput((digit + 1).toString(), currency) } - val initialDisplay = viewModel.uiState.value.displayText - val initialSats = viewModel.uiState.value.amountSats + val initialDisplay = viewModel.uiState.value.text + val initialSats = viewModel.uiState.value.sats // Try to add another decimal digit - should be blocked viewModel.handleNumberPadInput("9", currency) // Both display and sats should remain unchanged - assertEquals(initialDisplay, viewModel.uiState.value.displayText) - assertEquals(initialSats, viewModel.uiState.value.amountSats) + assertEquals(initialDisplay, viewModel.uiState.value.text) + assertEquals(initialSats, viewModel.uiState.value.sats) } @Test @@ -571,20 +571,20 @@ class AmountInputViewModelTest : BaseUnitTest() { // Build up to max decimal length (20 characters) val maxLengthText = "1".repeat(AmountInputViewModel.MAX_DECIMAL_LENGTH) maxLengthText.forEach { digit -> - if (viewModel.uiState.value.displayText.length < AmountInputViewModel.MAX_DECIMAL_LENGTH) { + if (viewModel.uiState.value.text.length < AmountInputViewModel.MAX_DECIMAL_LENGTH) { viewModel.handleNumberPadInput(digit.toString(), currency) } } - val initialDisplay = viewModel.uiState.value.displayText - val initialSats = viewModel.uiState.value.amountSats + val initialDisplay = viewModel.uiState.value.text + val initialSats = viewModel.uiState.value.sats // Try to add another digit - should be blocked viewModel.handleNumberPadInput("9", currency) // Both display and sats should remain unchanged - assertEquals(initialDisplay, viewModel.uiState.value.displayText) - assertEquals(initialSats, viewModel.uiState.value.amountSats) + assertEquals(initialDisplay, viewModel.uiState.value.text) + assertEquals(initialSats, viewModel.uiState.value.sats) } @Test @@ -597,15 +597,15 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput(digit.toString(), currency) } - val initialDisplay = viewModel.uiState.value.displayText - val initialSats = viewModel.uiState.value.amountSats + val initialDisplay = viewModel.uiState.value.text + val initialSats = viewModel.uiState.value.sats // Try to add another digit - should be blocked viewModel.handleNumberPadInput("9", currency) // Both display and sats should remain unchanged - assertEquals(initialDisplay, viewModel.uiState.value.displayText) - assertEquals(initialSats, viewModel.uiState.value.amountSats) + assertEquals(initialDisplay, viewModel.uiState.value.text) + assertEquals(initialSats, viewModel.uiState.value.sats) } @Test @@ -619,15 +619,15 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("3", currency) viewModel.handleNumberPadInput("4", currency) - val initialDisplay = viewModel.uiState.value.displayText - val initialSats = viewModel.uiState.value.amountSats + val initialDisplay = viewModel.uiState.value.text + val initialSats = viewModel.uiState.value.sats // Try to add another decimal point - should be blocked viewModel.handleNumberPadInput(KEY_DECIMAL, currency) // Both display and sats should remain unchanged - assertEquals(initialDisplay, viewModel.uiState.value.displayText) - assertEquals(initialSats, viewModel.uiState.value.amountSats) + assertEquals(initialDisplay, viewModel.uiState.value.text) + assertEquals(initialSats, viewModel.uiState.value.sats) } @Test @@ -637,15 +637,15 @@ class AmountInputViewModelTest : BaseUnitTest() { // Start with "0" viewModel.handleNumberPadInput("0", currency) - val initialDisplay = viewModel.uiState.value.displayText - val initialSats = viewModel.uiState.value.amountSats + val initialDisplay = viewModel.uiState.value.text + val initialSats = viewModel.uiState.value.sats // Try to add another "0" - should be blocked (replaced with same value) viewModel.handleNumberPadInput("0", currency) // Both display and sats should remain unchanged - assertEquals(initialDisplay, viewModel.uiState.value.displayText) - assertEquals(initialSats, viewModel.uiState.value.amountSats) + assertEquals(initialDisplay, viewModel.uiState.value.text) + assertEquals(initialSats, viewModel.uiState.value.sats) } @Test @@ -657,15 +657,15 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput(digit.toString(), currency) } - val initialDisplay = viewModel.uiState.value.displayText - val initialSats = viewModel.uiState.value.amountSats + val initialDisplay = viewModel.uiState.value.text + val initialSats = viewModel.uiState.value.sats // Try to add "000" - should be blocked (would exceed max length) viewModel.handleNumberPadInput(KEY_000, currency) // Both display and sats should remain unchanged - assertEquals(initialDisplay, viewModel.uiState.value.displayText) - assertEquals(initialSats, viewModel.uiState.value.amountSats) + assertEquals(initialDisplay, viewModel.uiState.value.text) + assertEquals(initialSats, viewModel.uiState.value.sats) } @Test @@ -677,7 +677,7 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("1", modernBtc) viewModel.handleNumberPadInput("0", modernBtc) viewModel.handleNumberPadInput("0", modernBtc) - val originalSats = viewModel.uiState.value.amountSats + val originalSats = viewModel.uiState.value.sats // Simulate toggle to fiat (would show something like "1.00" with 2 decimals) viewModel.setSats(originalSats, fiat) @@ -687,13 +687,13 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("0", fiat) viewModel.handleNumberPadInput("0", fiat) - val fiatSatsAfterFullInput = viewModel.uiState.value.amountSats + val fiatSatsAfterFullInput = viewModel.uiState.value.sats // Try to add another digit - should be blocked viewModel.handleNumberPadInput("5", fiat) // Sats amount should not change from the previous valid state - assertEquals(fiatSatsAfterFullInput, viewModel.uiState.value.amountSats) + assertEquals(fiatSatsAfterFullInput, viewModel.uiState.value.sats) } @Test @@ -707,14 +707,14 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("3", currency) viewModel.handleNumberPadInput("4", currency) - val initialSats = viewModel.uiState.value.amountSats + val initialSats = viewModel.uiState.value.sats // Delete should still work even at decimal limit viewModel.handleNumberPadInput(KEY_DELETE, currency) // Display should change and sats should be different - assertEquals("12.3", viewModel.uiState.value.displayText) - assertTrue(viewModel.uiState.value.amountSats != initialSats) + assertEquals("12.3", viewModel.uiState.value.text) + assertTrue(viewModel.uiState.value.sats != initialSats) } @Test @@ -727,17 +727,17 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("2", currency) viewModel.handleNumberPadInput("3", currency) - val initialDisplay = viewModel.uiState.value.displayText - val initialSats = viewModel.uiState.value.amountSats + val initialDisplay = viewModel.uiState.value.text + val initialSats = viewModel.uiState.value.sats // Try to add more decimal digits - should be blocked viewModel.handleNumberPadInput("4", currency) - assertEquals(initialDisplay, viewModel.uiState.value.displayText) - assertEquals(initialSats, viewModel.uiState.value.amountSats) + assertEquals(initialDisplay, viewModel.uiState.value.text) + assertEquals(initialSats, viewModel.uiState.value.sats) viewModel.handleNumberPadInput("5", currency) - assertEquals(initialDisplay, viewModel.uiState.value.displayText) - assertEquals(initialSats, viewModel.uiState.value.amountSats) + assertEquals(initialDisplay, viewModel.uiState.value.text) + assertEquals(initialSats, viewModel.uiState.value.sats) } @Test @@ -750,15 +750,15 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput(KEY_DECIMAL, btc) viewModel.handleNumberPadInput("0", btc) viewModel.handleNumberPadInput("1", btc) - val originalSats = viewModel.uiState.value.amountSats + val originalSats = viewModel.uiState.value.sats // Toggle to fiat viewModel.switchUnit(fiat) - val satsAfterToggle = viewModel.uiState.value.amountSats + val satsAfterToggle = viewModel.uiState.value.sats // Toggle back to bitcoin viewModel.switchUnit(btc) - val finalSats = viewModel.uiState.value.amountSats + val finalSats = viewModel.uiState.value.sats // Sats amount should be preserved throughout assertEquals(originalSats, satsAfterToggle) @@ -780,17 +780,17 @@ class AmountInputViewModelTest : BaseUnitTest() { ) viewModel.handleNumberPadInput("5", btcClassic) - val originalSats = viewModel.uiState.value.amountSats + val originalSats = viewModel.uiState.value.sats // Switch unit to fiat viewModel.switchUnit(btcClassic) - val fiatDisplay = viewModel.uiState.value.displayText - val fiatSats = viewModel.uiState.value.amountSats + val fiatDisplay = viewModel.uiState.value.text + val fiatSats = viewModel.uiState.value.sats // Switch unit back to bitcoin viewModel.switchUnit(fiat) - val finalDisplay = viewModel.uiState.value.displayText - val finalSats = viewModel.uiState.value.amountSats + val finalDisplay = viewModel.uiState.value.text + val finalSats = viewModel.uiState.value.sats assertEquals("Sats amount should be preserved", originalSats, fiatSats) assertEquals("Sats amount should be preserved after round trip", originalSats, finalSats) @@ -831,8 +831,8 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("9", btcClassic) viewModel.handleNumberPadInput("2", btcClassic) - val originalSats = viewModel.uiState.value.amountSats - val originalDisplay = viewModel.uiState.value.displayText + val originalSats = viewModel.uiState.value.sats + val originalDisplay = viewModel.uiState.value.text // Switch to fiat and back val btcState = currencyRepo.currencyState.value.copy(primaryDisplay = PrimaryDisplay.BITCOIN) @@ -840,8 +840,8 @@ class AmountInputViewModelTest : BaseUnitTest() { val fiatState = currencyRepo.currencyState.value.copy(primaryDisplay = PrimaryDisplay.FIAT) viewModel.switchUnit(fiatState) // Fiat -> Bitcoin - val finalSats = viewModel.uiState.value.amountSats - val finalDisplay = viewModel.uiState.value.displayText + val finalSats = viewModel.uiState.value.sats + val finalDisplay = viewModel.uiState.value.text // Precision should be maintained assertEquals("Precise sats amount should be preserved", originalSats, finalSats) @@ -856,20 +856,20 @@ class AmountInputViewModelTest : BaseUnitTest() { // Test with empty input viewModel.switchUnit(fiat) - assertEquals("Empty input should remain 0 sats", 0L, viewModel.uiState.value.amountSats) - assertEquals("Empty input should have empty display", "", viewModel.uiState.value.displayText) + assertEquals("Empty input should remain 0 sats", 0L, viewModel.uiState.value.sats) + assertEquals("Empty input should have empty display", "", viewModel.uiState.value.text) // Test with partial input "0." viewModel.handleNumberPadInput("0", fiat) viewModel.handleNumberPadInput(KEY_DECIMAL, fiat) - val partialSats = viewModel.uiState.value.amountSats + val partialSats = viewModel.uiState.value.sats // Toggle to Bitcoin and back viewModel.switchUnit(btcClassic) viewModel.switchUnit(fiat) // Should handle gracefully without crashes - assertEquals("Partial input sats should be preserved", partialSats, viewModel.uiState.value.amountSats) + assertEquals("Partial input sats should be preserved", partialSats, viewModel.uiState.value.sats) // Test toggling with just decimal point from Bitcoin side viewModel.clearInput() @@ -897,13 +897,13 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("0", btcModern) viewModel.handleNumberPadInput("0", btcModern) - val modernDisplay = viewModel.uiState.value.displayText - val satsAmount = viewModel.uiState.value.amountSats + val modernDisplay = viewModel.uiState.value.text + val satsAmount = viewModel.uiState.value.sats // Note: Since we're using NoopAmountHandler, we can't actually test currency conversion // But we can test that switchUnit doesn't crash and preserves sats amount viewModel.switchUnit(fiat) - val afterToggleSats = viewModel.uiState.value.amountSats + val afterToggleSats = viewModel.uiState.value.sats // Verify display format for modern Bitcoin assertEquals("Modern Bitcoin should show formatted sats", "1 000", modernDisplay) @@ -933,12 +933,12 @@ class AmountInputViewModelTest : BaseUnitTest() { // Test case 1: Use realistic amount that doesn't exceed MAX_AMOUNT // Input "5" BTC (5 * 100,000,000 = 500,000,000 sats, which is under MAX_AMOUNT) viewModel.handleNumberPadInput("5", btcClassic) - val largeBtcSats = viewModel.uiState.value.amountSats + val largeBtcSats = viewModel.uiState.value.sats // Toggle from Bitcoin to Fiat - pass current Bitcoin state val currentBtcState = currencyRepo.currencyState.value.copy(primaryDisplay = PrimaryDisplay.BITCOIN) viewModel.switchUnit(currentBtcState) - val largeBtcFiatDisplay = viewModel.uiState.value.displayText + val largeBtcFiatDisplay = viewModel.uiState.value.text // 5 BTC is a substantial amount and should not convert to tiny values like $0.10 assertNotEquals("5 BTC should not convert to $0.10", "0.10", largeBtcFiatDisplay) @@ -952,11 +952,11 @@ class AmountInputViewModelTest : BaseUnitTest() { viewModel.handleNumberPadInput("0", btcClassic) viewModel.handleNumberPadInput("0", btcClassic) viewModel.handleNumberPadInput("1", btcClassic) - val smallBtcSats = viewModel.uiState.value.amountSats + val smallBtcSats = viewModel.uiState.value.sats // Toggle from Bitcoin to Fiat - pass current Bitcoin state viewModel.switchUnit(currentBtcState) - val smallBtcFiatDisplay = viewModel.uiState.value.displayText + val smallBtcFiatDisplay = viewModel.uiState.value.text // 0.001 BTC should convert to reasonable fiat amount (not 0 or extremely large) assertTrue("Small BTC amount should have reasonable sats value", smallBtcSats > 0)