From 17e054edfaf4540ded8ad680ba0e54686054d675 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 20 Aug 2025 11:01:14 -0300 Subject: [PATCH 1/5] feat: implement SyncNodeView --- .../screens/transfer/SpendingAmountScreen.kt | 200 +++++++++++------- .../to/bitkit/viewmodels/TransferViewModel.kt | 6 +- 2 files changed, 131 insertions(+), 75 deletions(-) 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 62638cb1e..8fcb65399 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 @@ -34,6 +34,7 @@ import to.bitkit.ui.components.MoneySSB import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.UnitButton import to.bitkit.ui.components.VerticalSpacer @@ -59,6 +60,7 @@ fun SpendingAmountScreen( ) { val currencies = LocalCurrencies.current val uiState by viewModel.spendingUiState.collectAsStateWithLifecycle() + val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.updateLimits(retry = true) @@ -86,6 +88,7 @@ fun SpendingAmountScreen( ) Content( + isNodeRunning = isNodeRunning, uiState = uiState, currencies = currencies, onBackClick = onBackClick, @@ -99,6 +102,7 @@ fun SpendingAmountScreen( @Composable private fun Content( + isNodeRunning: Boolean, uiState: TransferToSpendingUiState, currencies: CurrencyUiState, onBackClick: () -> Unit, @@ -114,92 +118,120 @@ private fun Content( onBackClick = onBackClick, actions = { CloseNavIcon(onCloseClick) }, ) - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize() - .imePadding() - .testTag("SpendingAmount") - ) { - VerticalSpacer(16.dp) - Display( - text = stringResource(R.string.lightning__spending_amount__title) - .withAccent(accentColor = Colors.Purple) - ) - NumberPadTextField( - input = uiState.input, - displayUnit = currencies.displayUnit, - showSecondaryField = false, - primaryDisplay = currencies.primaryDisplay, + if (isNodeRunning) { + SpendingAmountNodeRunning( + uiState = uiState, + currencies = currencies, + onClickQuarter = onClickQuarter, + onClickMaxAmount = onClickMaxAmount, + onConfirmAmount = onConfirmAmount, + onInputChange = onInputChange, + ) + } else { + SyncNodeView( modifier = Modifier .fillMaxWidth() - .testTag("SpendingAmountNumberField") + .weight(1f) ) + } + } +} + +@Composable +private fun SpendingAmountNodeRunning( + uiState: TransferToSpendingUiState, + currencies: CurrencyUiState, + onClickQuarter: () -> Unit, + onClickMaxAmount: () -> Unit, + onConfirmAmount: () -> Unit, + onInputChange: (String) -> Unit, +) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .imePadding() + .testTag("SpendingAmount") + ) { + VerticalSpacer(16.dp) + Display( + text = stringResource(R.string.lightning__spending_amount__title) + .withAccent(accentColor = Colors.Purple) + ) - FillHeight() + NumberPadTextField( + input = uiState.input, + displayUnit = currencies.displayUnit, + showSecondaryField = false, + primaryDisplay = currencies.primaryDisplay, + modifier = Modifier + .fillMaxWidth() + .testTag("SpendingAmountNumberField") + ) - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .padding(vertical = 8.dp) - .testTag("SendAmountNumberPad") - ) { - Column { - Text13Up( - text = stringResource(R.string.wallet__send_available), - color = Colors.White64, - modifier = Modifier.testTag("SpendingAmountAvailable") - ) - Spacer(modifier = Modifier.height(8.dp)) - MoneySSB(sats = uiState.balanceAfterFee, modifier = Modifier.testTag("SpendingAmountUnit")) - } - FillWidth() - UnitButton(color = Colors.Purple) - // 25% Button - 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, - onClick = onClickMaxAmount, - modifier = Modifier.testTag("SpendingAmountMax") + FillHeight() + + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(vertical = 8.dp) + .testTag("SendAmountNumberPad") + ) { + Column { + Text13Up( + text = stringResource(R.string.wallet__send_available), + color = Colors.White64, + modifier = Modifier.testTag("SpendingAmountAvailable") ) + Spacer(modifier = Modifier.height(8.dp)) + MoneySSB(sats = uiState.balanceAfterFee, modifier = Modifier.testTag("SpendingAmountUnit")) } - 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() + FillWidth() + UnitButton(color = Colors.Purple) + // 25% Button + 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, + onClick = onClickMaxAmount, + modifier = Modifier.testTag("SpendingAmountMax") + ) + } + HorizontalDivider() - VerticalSpacer(8.dp) + VerticalSpacer(16.dp) - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onConfirmAmount, - enabled = uiState.satsAmount != 0L && uiState.satsAmount <= uiState.maxAllowedToSend, - isLoading = uiState.isLoading, - modifier = Modifier.testTag("SpendingAmountContinue") - ) + 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() + ) - VerticalSpacer(16.dp) - } + VerticalSpacer(8.dp) + + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onConfirmAmount, + enabled = uiState.satsAmount != 0L && uiState.satsAmount <= uiState.maxAllowedToSend, + isLoading = uiState.isLoading, + modifier = Modifier.testTag("SpendingAmountContinue") + ) + + VerticalSpacer(16.dp) } } @@ -208,6 +240,7 @@ private fun Content( private fun Preview() { AppThemeSurface { Content( + isNodeRunning = true, uiState = TransferToSpendingUiState(input = "5 000"), currencies = CurrencyUiState(), onBackClick = {}, @@ -225,6 +258,25 @@ private fun Preview() { private fun Preview2() { AppThemeSurface { Content( + isNodeRunning = true, + uiState = TransferToSpendingUiState(input = "5 000"), + currencies = CurrencyUiState(), + onBackClick = {}, + onCloseClick = {}, + onClickQuarter = {}, + onClickMaxAmount = {}, + onConfirmAmount = {}, + onInputChange = {}, + ) + } +} + +@Preview(showBackground = true, device = NEXUS_5) +@Composable +private fun Preview3() { + AppThemeSurface { + Content( + isNodeRunning = false, uiState = TransferToSpendingUiState(input = "5 000"), currencies = CurrencyUiState(), onBackClick = {}, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index d07517886..1087bb8a3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -63,6 +64,9 @@ class TransferViewModel @Inject constructor( val lightningSetupStep: StateFlow = settingsStore.data.map { it.lightningSetupStep } .stateIn(viewModelScope, SharingStarted.Lazily, 0) + val isNodeRunning = lightningRepo.lightningState.map { it.nodeStatus?.isRunning ?: false } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + private val _selectedChannelIdsState = MutableStateFlow>(emptySet()) val selectedChannelIdsState = _selectedChannelIdsState.asStateFlow() @@ -137,7 +141,7 @@ class TransferViewModel @Inject constructor( _spendingUiState.update { it.copy(overrideSats = minAmount, isLoading = false) } return@launch } - + // TODO Collect isNodeRunning here blocktankRepo.createOrder(_spendingUiState.value.satsAmount.toULong()) .onSuccess { order -> settingsStore.update { it.copy(lightningSetupStep = 0) } From 2ebdb1d81bfa2e7a92b884e6569372567775c804 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 20 Aug 2025 11:18:57 -0300 Subject: [PATCH 2/5] fix: wait for node runs before performs node related calls --- .../java/to/bitkit/viewmodels/TransferViewModel.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 1087bb8a3..c1ccd8593 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -24,6 +24,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.data.CacheStore @@ -42,6 +44,7 @@ import javax.inject.Inject import kotlin.math.max import kotlin.math.min import kotlin.math.roundToLong +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds const val RETRY_INTERVAL_MS = 1 * 60 * 1000L // 1 minutes in ms @@ -141,7 +144,11 @@ class TransferViewModel @Inject constructor( _spendingUiState.update { it.copy(overrideSats = minAmount, isLoading = false) } return@launch } - // TODO Collect isNodeRunning here + + withTimeoutOrNull(1.minutes) { + isNodeRunning.first { it } + } + blocktankRepo.createOrder(_spendingUiState.value.satsAmount.toULong()) .onSuccess { order -> settingsStore.update { it.copy(lightningSetupStep = 0) } @@ -271,6 +278,10 @@ class TransferViewModel @Inject constructor( // Get the max available balance discounting onChain fee val availableAmount = walletRepo.getMaxSendAmount() + withTimeoutOrNull(1.minutes) { + isNodeRunning.first { it } + } + // Calculate the LSP fee to the total balance blocktankRepo.estimateOrderFee( spendingBalanceSats = availableAmount, From 578d07a181f4faf408cd209b5a42cf0c74410c2d Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 20 Aug 2025 11:31:20 -0300 Subject: [PATCH 3/5] refactor: remove unnecessary retry logic --- .../screens/transfer/SpendingAmountScreen.kt | 2 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 30 ++++++------------- 2 files changed, 10 insertions(+), 22 deletions(-) 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 8fcb65399..817298daa 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 @@ -63,7 +63,7 @@ fun SpendingAmountScreen( val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle() LaunchedEffect(Unit) { - viewModel.updateLimits(retry = true) + viewModel.updateLimits() } LaunchedEffect(Unit) { diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index c1ccd8593..6ad4a665b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R @@ -78,7 +77,6 @@ class TransferViewModel @Inject constructor( val transferEffects = MutableSharedFlow() fun setTransferEffect(effect: TransferEffect) = viewModelScope.launch { transferEffects.emit(effect) } - var retryTimes = 0 var maxLspFee = 0uL // region Spending @@ -90,7 +88,7 @@ class TransferViewModel @Inject constructor( overrideSats = it.maxAllowedToSend, ) } - updateLimits(false) + updateLimits() } fun onClickQuarter() { @@ -113,7 +111,7 @@ class TransferViewModel @Inject constructor( overrideSats = min(quarter, it.maxAllowedToSend), ) } - updateLimits(false) + updateLimits() } fun onConfirmAmount() { @@ -183,13 +181,12 @@ class TransferViewModel @Inject constructor( _spendingUiState.update { it.copy(satsAmount = sats, overrideSats = null) } - retryTimes = 0 - updateLimits(retry = false) + updateLimits() } - fun updateLimits(retry: Boolean) { + fun updateLimits() { updateTransferValues(_spendingUiState.value.satsAmount.toULong()) - updateAvailableAmount(retry = retry) + updateAvailableAmount() } fun onAdvancedOrderCreated(order: IBtOrder) { @@ -271,7 +268,7 @@ class TransferViewModel @Inject constructor( setTransferEffect(TransferEffect.OnOrderCreated) } - private fun updateAvailableAmount(retry: Boolean) { + private fun updateAvailableAmount() { viewModelScope.launch { _spendingUiState.update { it.copy(isLoading = true) } @@ -287,7 +284,6 @@ class TransferViewModel @Inject constructor( spendingBalanceSats = availableAmount, receivingBalanceSats = _transferValues.value.maxLspBalance ).onSuccess { estimate -> - retryTimes = 0 maxLspFee = estimate.feeSat // Calculate the available balance to send after LSP fee @@ -305,17 +301,9 @@ class TransferViewModel @Inject constructor( ) } }.onFailure { exception -> - if (exception is ServiceError.NodeNotStarted && retry) { - // Retry after delay - Logger.warn("Error getting the available amount. Node not started. trying again in 2 seconds") - delay(2.seconds) - updateAvailableAmount(retry = retryTimes <= RETRY_LIMIT) - retryTimes++ - } else { - _spendingUiState.update { it.copy(isLoading = false) } - Logger.error("Failure", exception) - setTransferEffect(TransferEffect.ToastException(exception)) - } + _spendingUiState.update { it.copy(isLoading = false) } + Logger.error("Failure", exception) + setTransferEffect(TransferEffect.ToastException(exception)) } } } From 4270f8ec1d9b6b185eec2a44785795eb029f17d2 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 20 Aug 2025 13:18:11 -0300 Subject: [PATCH 4/5] fix: implement adaptative spacing --- app/src/main/java/to/bitkit/ui/components/Spacers.kt | 10 ++++++++++ .../bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Spacers.kt b/app/src/main/java/to/bitkit/ui/components/Spacers.kt index 724409f2a..8d54132a1 100644 --- a/app/src/main/java/to/bitkit/ui/components/Spacers.kt +++ b/app/src/main/java/to/bitkit/ui/components/Spacers.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -18,6 +19,15 @@ fun VerticalSpacer(height: Dp) { Spacer(modifier = Modifier.height(height)) } +@Composable +fun ColumnScope.VerticalSpacer(minHeight: Dp, maxHeight: Dp) { + Spacer( + modifier = Modifier + .weight(1f) + .sizeIn(minHeight = minHeight, maxHeight = maxHeight) + ) +} + @Composable fun HorizontalSpacer(width: Dp) { Spacer(modifier = Modifier.width(width)) 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 817298daa..8aea708b5 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -154,12 +155,15 @@ private fun SpendingAmountNodeRunning( .imePadding() .testTag("SpendingAmount") ) { - VerticalSpacer(16.dp) + VerticalSpacer(minHeight = 16.dp, maxHeight = 32.dp) + Display( text = stringResource(R.string.lightning__spending_amount__title) .withAccent(accentColor = Colors.Purple) ) + FillHeight() + NumberPadTextField( input = uiState.input, displayUnit = currencies.displayUnit, From 54c38d9e1da1f91fdc139dc0d30a93d85d7939d1 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 20 Aug 2025 13:19:56 -0300 Subject: [PATCH 5/5] chore: lint --- .../java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 1 - 1 file changed, 1 deletion(-) 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 8aea708b5..0da240830 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 @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect