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/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)
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 541542d4f..1fa15b7ff 100644
--- a/app/src/main/java/to/bitkit/models/Currency.kt
+++ b/app/src/main/java/to/bitkit/models/Currency.kt
@@ -8,11 +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 BTC_PLACEHOLDER = "0.00000000"
-const val SATS_PLACEHOLDER = "0"
+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(
@@ -42,11 +47,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 +57,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 +72,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,18 +91,43 @@ 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 symbols = DecimalFormatSymbols(locale).apply {
+ groupingSeparator = SATS_GROUPING_SEPARATOR
}
- val formatter = DecimalFormat("#,###", formatSymbols).apply {
+ val formatter = DecimalFormat("#,###", symbols).apply {
isGroupingUsed = true
}
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 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 bfd971892..a8629a23b 100644
--- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
@@ -29,11 +29,12 @@ 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
@@ -41,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,
@@ -49,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()
@@ -153,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 = it.primaryDisplay.not()) }
+ }
+
+ override suspend fun switchUnit(unit: PrimaryDisplay): PrimaryDisplay = withContext(bgDispatcher) {
+ unit.not().also { nextValue ->
+ setPrimaryDisplayUnit(nextValue)
}
}
@@ -177,12 +178,13 @@ class CurrencyRepo @Inject constructor(
refresh()
}
- fun getCurrencySymbol(): String {
- return _currencyState.value.currencySymbol
- }
+ fun getCurrentRate(currency: String): FxRate {
+ val rates = _currencyState.value.rates
+ val rate = rates.firstOrNull { it.quote == currency }
- fun getCurrentRate(currency: String): FxRate? {
- return _currencyState.value.rates.firstOrNull { it.quote == currency }
+ return checkNotNull(rate) {
+ "Rate not found for currency: $currency in: ${rates.joinToString { it.quote }}"
+ }
}
fun convertSatsToFiat(
@@ -190,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,
@@ -216,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))
@@ -233,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(
@@ -256,5 +255,22 @@ data class CurrencyState(
val currencySymbol: String = "$",
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
- val lastSuccessfulRefresh: Long? = null
+ val lastSuccessfulRefresh: Long? = null,
)
+
+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 {
+ 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) = unit.not()
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt
index 575519932..4b0b820b4 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
@@ -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.checkGeoBlock().second) {
return@withContext Result.failure(ServiceError.GeoBlocked)
@@ -427,6 +422,8 @@ class WalletRepo @Inject constructor(
if (coreService.checkGeoBlock().first) 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)
@@ -466,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()
@@ -525,7 +501,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/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index 8a8c9a745..70ac63ca0 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -1073,7 +1073,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/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 ce2b0cd04..d0d012e98 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,7 +113,7 @@ 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? {
@@ -118,7 +122,12 @@ fun rememberMoneyText(
val symbol = if (unit == PrimaryDisplay.BITCOIN) BITCOIN_SYMBOL else "$"
return buildString {
if (showSymbol) append("$symbol ")
- append(sats.formatToModernDisplay())
+ if (unit == PrimaryDisplay.BITCOIN) {
+ append(sats.formatToModernDisplay())
+ } else {
+ 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..7f1cb0257 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.text,
+ unit = currencies.primaryDisplay,
+ placeholder = viewModel.getPlaceholder(currencies),
showPlaceholder = true,
- satoshis = satoshis.toLongOrNull() ?: 0,
- currencySymbol = currency.getCurrencySymbol()
+ satoshis = uiState.value.sats,
+ 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 f157a7678..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,29 +10,25 @@ 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.CurrencyViewModel
@Composable
fun UnitButton(
modifier: Modifier = Modifier,
- onClick: () -> Unit = {},
color: Color = Colors.Brand,
- primaryDisplay: PrimaryDisplay = LocalCurrencies.current.primaryDisplay,
+ currencies: CurrencyState = LocalCurrencies.current,
+ currencyVM: CurrencyViewModel? = currencyViewModel,
+ onClick: () -> Unit = { currencyVM?.switchUnit() },
) {
- 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 = CurrencyState(primaryDisplay = PrimaryDisplay.BITCOIN))
+ UnitButton(currencies = CurrencyState(primaryDisplay = PrimaryDisplay.FIAT))
}
}
}
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/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..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
@@ -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.sats) },
)
}
+@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.sats != 0L && amountUiState.sats <= 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..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
@@ -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.sats.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.sats.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 c0cefed4d..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
@@ -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,29 +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.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.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
@@ -56,24 +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 kotlin.time.Duration.Companion.milliseconds
+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()
@@ -81,86 +75,56 @@ 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.sats >= (minCjitSats?.toLong() ?: 0),
onBack = onBack,
+ onClickMin = { amountInputViewModel.setSats(it, currencies) },
onContinue = {
- val sats = satsAmount.toULong()
+ val sats = amountInputUiState.sats
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.toULong())
+ onCjitCreated(
+ CjitEntryDetails(
+ networkFeeSat = entry.networkFeeSat.toLong(),
+ serviceFeeSat = entry.serviceFeeSat.toLong(),
+ channelSizeSat = entry.channelSizeSat.toLong(),
+ feeSat = entry.feeSat.toLong(),
+ receiveAmountSats = sats,
+ 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
}
}
)
}
+@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 = {},
) {
@@ -184,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")
)
@@ -216,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()
@@ -238,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")
@@ -256,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(),
)
@@ -273,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/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 = {},
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 e27859266..c3d62cc30 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 3f37ac78e..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
@@ -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.sats) {
+ currentOnEvent(SendEvent.AmountChange(amountInputUiState.sats.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()
@@ -295,7 +275,7 @@ private fun SendAmountNodeRunning(
text = stringResource(R.string.common__continue),
enabled = uiState.isAmountInputValid,
isLoading = uiState.isLoading,
- onClick = { onEvent(SendEvent.AmountContinue(uiState.amountInput)) },
+ onClick = onContinue,
modifier = Modifier.testTag("ContinueAmount")
)
@@ -307,7 +287,7 @@ private fun SendAmountNodeRunning(
@Composable
private fun PaymentMethodButton(
uiState: SendUiState,
- onEvent: (SendEvent) -> Unit,
+ onClick: () -> Unit,
) {
val testId = when {
uiState.isUnified -> "switch"
@@ -324,7 +304,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)
@@ -338,22 +318,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),
)
}
}
@@ -364,23 +335,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,
)
}
}
@@ -392,22 +361,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),
)
}
}
@@ -419,18 +379,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(),
)
}
@@ -443,31 +396,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),
)
}
}
@@ -479,9 +425,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 = "",
@@ -495,16 +441,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),
)
}
}
@@ -516,21 +455,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/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/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 8a610eab6..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,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.SATS_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("$SATS_GROUPING_SEPARATOR", "").toLongOrNull() ?: return text
+ return longValue.formatToModernDisplay()
}
private fun formatClassicDisplay(text: String): String {
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..08c49167a
--- /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(
+ text = formatDisplayTextFromAmount(newAmount, primaryDisplay, isModern = true),
+ sats = 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(
+ text = if (primaryDisplay == PrimaryDisplay.FIAT) {
+ formatFiatGroupingOnly(newText)
+ } else {
+ newText
+ },
+ sats = 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(
+ sats = 0,
+ text = "",
+ errorKey = null
+ )
+ }
+ }
+ }
+ }
+
+ fun setSats(sats: Long, currencyState: CurrencyState) {
+ val primaryDisplay = currencyState.primaryDisplay
+ val isModern = currencyState.displayUnit.isModern()
+
+ _uiState.update {
+ it.copy(
+ sats = sats,
+ text = formatDisplayTextFromAmount(sats, primaryDisplay, isModern)
+ )
+ }
+ // Update raw input text based on the formatted display
+ rawInputText = when (primaryDisplay) {
+ PrimaryDisplay.FIAT -> _uiState.value.text.replace(",", "")
+ else -> _uiState.value.text
+ }
+ }
+
+ /**
+ * 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.sats
+ if (amountSats > 0) {
+ _uiState.update {
+ it.copy(
+ text = formatDisplayTextFromAmount(amountSats, newPrimaryDisplay, isModern)
+ )
+ }
+ // Update raw input text based on the new display
+ rawInputText = when (newPrimaryDisplay) {
+ 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
+ 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(text = formatFiatGroupingOnly(rawInputText)) }
+ }
+ }
+
+ PrimaryDisplay.BITCOIN -> {
+ // Converting from fiat to bitcoin
+ val sats = convertFiatToSats(currentRawInput)
+ if (sats != null) {
+ rawInputText = formatBitcoinFromSats(sats, isModern)
+ _uiState.update { it.copy(text = 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.text.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.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 ""
+ } else {
+ PLACEHOLDER_CLASSIC_DECIMALS
+ }
+ }
+ }
+
+ PrimaryDisplay.FIAT -> {
+ 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 ""
+ } 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 sats: Long = 0L,
+ val text: 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 729f19681..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,
@@ -111,13 +112,7 @@ class AppViewModel @Inject constructor(
private set
val isGeoBlocked = lightningRepo.lightningState.map { it.isGeoBlocked }
- .stateIn(
- viewModelScope,
- SharingStarted.WhileSubscribed(
- 5000
- ),
- false
- )
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
private val _sendUiState = MutableStateFlow(SendUiState())
val sendUiState = _sendUiState.asStateFlow()
@@ -291,9 +286,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)
@@ -347,11 +342,11 @@ class AppViewModel @Inject constructor(
}
}
- private fun onAmountChange(value: String) = viewModelScope.launch {
+ private suspend fun onAmountChange(amount: ULong) {
_sendUiState.update {
it.copy(
- amountInput = value,
- isAmountInputValid = validateAmount(value)
+ amount = amount,
+ isAmountInputValid = validateAmount(amount),
)
}
}
@@ -401,7 +396,7 @@ class AppViewModel @Inject constructor(
}
}
- private fun onPaymentMethodSwitch() = viewModelScope.launch {
+ private suspend fun onPaymentMethodSwitch() {
val nextPaymentMethod = when (_sendUiState.value.payMethod) {
SendMethod.ONCHAIN -> SendMethod.LIGHTNING
SendMethod.LIGHTNING -> SendMethod.ONCHAIN
@@ -409,15 +404,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,
)
}
@@ -450,26 +444,21 @@ class AppViewModel @Inject constructor(
}
private suspend 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()
@@ -824,11 +813,11 @@ class AppViewModel @Inject constructor(
return false
}
- private fun resetAmountInput() = viewModelScope.launch {
+ private fun resetAmountInput() {
_sendUiState.update { state ->
state.copy(
- amountInput = state.amount.toString(),
- isAmountInputValid = validateAmount(state.amount.toString()),
+ amount = 0u,
+ isAmountInputValid = false,
)
}
}
@@ -1518,7 +1507,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,
@@ -1583,8 +1571,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 17ae39f11..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()
}
}
@@ -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()
@@ -61,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 c5ee7f7e4..46a7e23ed 100644
--- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt
@@ -75,7 +75,6 @@ class WalletViewModel @Inject constructor(
_uiState.update {
it.copy(
onchainAddress = state.onchainAddress,
- balanceInput = state.balanceInput,
bolt11 = state.bolt11,
bip21 = state.bip21,
bip21AmountSats = state.bip21AmountSats,
@@ -291,10 +290,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) {
@@ -306,7 +301,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/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt
index 8a5300640..fba47357f 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.checkGeoBlock()).thenReturn(Pair(false, 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.checkGeoBlock()).thenReturn(Pair(false, 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
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..2c8bac799
--- /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.text)
+ assertEquals(1L, viewModel.uiState.value.sats)
+
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals("10", viewModel.uiState.value.text)
+ assertEquals(10L, viewModel.uiState.value.sats)
+
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals("100", viewModel.uiState.value.text)
+ assertEquals(100L, viewModel.uiState.value.sats)
+
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals("1 000", viewModel.uiState.value.text)
+ assertEquals(1000L, viewModel.uiState.value.sats)
+ }
+
+ @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.sats)
+
+ // Try to exceed max amount - should be blocked
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals(AmountInputViewModel.MAX_AMOUNT, viewModel.uiState.value.sats)
+ 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.text)
+ assertEquals(1000L, viewModel.uiState.value.sats)
+ }
+
+ // 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.text)
+
+ viewModel.handleNumberPadInput(KEY_DECIMAL, currency)
+ assertEquals("1.", viewModel.uiState.value.text)
+
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals("1.0", viewModel.uiState.value.text)
+
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals("1.00", viewModel.uiState.value.text)
+ }
+
+ @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.text)
+
+ // Try to add more decimals - should be blocked
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals("1.00000000", viewModel.uiState.value.text) // 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.sats <= 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.text)
+ }
+
+ @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.text) // Modern Bitcoin shows empty for 0
+
+ viewModel.handleNumberPadInput("1", currency)
+ assertEquals("1", viewModel.uiState.value.text)
+ }
+
+ @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.text)
+
+ viewModel.handleNumberPadInput(KEY_DELETE, currency)
+ assertEquals("", viewModel.uiState.value.text)
+ }
+
+ @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.text)
+
+ // Delete back to "1."
+ viewModel.handleNumberPadInput(KEY_DELETE, currency)
+ assertEquals("1.5", viewModel.uiState.value.text)
+
+ viewModel.handleNumberPadInput(KEY_DELETE, currency)
+ assertEquals("1.", viewModel.uiState.value.text)
+
+ viewModel.handleNumberPadInput(KEY_DELETE, currency)
+ assertEquals("1", viewModel.uiState.value.text)
+
+ viewModel.handleNumberPadInput(KEY_DELETE, currency)
+ assertEquals("", viewModel.uiState.value.text)
+ }
+
+ @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.text)
+
+ // Try to add another decimal point
+ viewModel.handleNumberPadInput(KEY_DECIMAL, currency)
+ assertEquals("1.5", viewModel.uiState.value.text) // 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.text)
+ assertEquals(100L, viewModel.uiState.value.sats)
+
+ viewModel.clearInput()
+
+ assertEquals("", viewModel.uiState.value.text)
+ assertEquals(0L, viewModel.uiState.value.sats)
+ 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.sats)
+ assertEquals("12 345", viewModel.uiState.value.text)
+ }
+
+ @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.sats)
+ assertEquals("115.15", viewModel.uiState.value.text)
+
+ viewModel.switchUnit(currency)
+ assertEquals("100 000", viewModel.uiState.value.text)
+ }
+
+ @Test
+ fun `fiat grouping separators work correctly`() = test {
+ val currency = mockCurrency(PrimaryDisplay.FIAT)
+
+ viewModel.handleNumberPadInput("1", currency)
+ assertEquals("1", viewModel.uiState.value.text)
+
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals("10", viewModel.uiState.value.text)
+
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals("100", viewModel.uiState.value.text)
+
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals("1,000", viewModel.uiState.value.text)
+ }
+
+ @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.text)
+
+ viewModel.handleNumberPadInput(KEY_DECIMAL, currency)
+ viewModel.handleNumberPadInput("0", currency)
+ viewModel.handleNumberPadInput("0", currency)
+ assertEquals("1,000.00", viewModel.uiState.value.text)
+
+ // Delete character by character
+ viewModel.handleNumberPadInput(KEY_DELETE, currency)
+ assertEquals("1,000.0", viewModel.uiState.value.text)
+
+ viewModel.handleNumberPadInput(KEY_DELETE, currency)
+ assertEquals("1,000.", viewModel.uiState.value.text)
+
+ viewModel.handleNumberPadInput(KEY_DELETE, currency)
+ assertEquals("1,000", viewModel.uiState.value.text)
+ }
+
+ @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.text)
+ }
+
+ @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.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.text)
+ }
+
+ @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.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.text)
+ assertEquals(initialSats, viewModel.uiState.value.sats)
+ }
+
+ @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.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.text)
+ assertEquals(initialSats, viewModel.uiState.value.sats)
+ }
+
+ @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.text.length < AmountInputViewModel.MAX_DECIMAL_LENGTH) {
+ viewModel.handleNumberPadInput(digit.toString(), currency)
+ }
+ }
+
+ 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.text)
+ assertEquals(initialSats, viewModel.uiState.value.sats)
+ }
+
+ @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.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.text)
+ assertEquals(initialSats, viewModel.uiState.value.sats)
+ }
+
+ @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.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.text)
+ assertEquals(initialSats, viewModel.uiState.value.sats)
+ }
+
+ @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.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.text)
+ assertEquals(initialSats, viewModel.uiState.value.sats)
+ }
+
+ @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.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.text)
+ assertEquals(initialSats, viewModel.uiState.value.sats)
+ }
+
+ @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.sats
+
+ // 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.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.sats)
+ }
+
+ @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.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.text)
+ assertTrue(viewModel.uiState.value.sats != 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.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.text)
+ assertEquals(initialSats, viewModel.uiState.value.sats)
+
+ viewModel.handleNumberPadInput("5", currency)
+ assertEquals(initialDisplay, viewModel.uiState.value.text)
+ assertEquals(initialSats, viewModel.uiState.value.sats)
+ }
+
+ @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.sats
+
+ // Toggle to fiat
+ viewModel.switchUnit(fiat)
+ val satsAfterToggle = viewModel.uiState.value.sats
+
+ // Toggle back to bitcoin
+ viewModel.switchUnit(btc)
+ val finalSats = viewModel.uiState.value.sats
+
+ // 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.sats
+
+ // Switch unit to fiat
+ viewModel.switchUnit(btcClassic)
+ 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.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)
+ 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.sats
+ val originalDisplay = viewModel.uiState.value.text
+
+ // 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.sats
+ val finalDisplay = viewModel.uiState.value.text
+
+ // 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.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.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.sats)
+
+ // 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.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.sats
+
+ // 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.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.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)
+ 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.sats
+
+ // Toggle from Bitcoin to Fiat - pass current Bitcoin state
+ viewModel.switchUnit(currentBtcState)
+ 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)
+ 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