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 7ff13a203..19ed514f5 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 @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.wallets.send +import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -11,6 +12,7 @@ import to.bitkit.test.annotations.ComposeUi import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState import to.bitkit.viewmodels.previewAmountInputViewModel +import kotlin.test.assertFalse @ComposeUi class SendAmountContentTest { @@ -107,14 +109,14 @@ class SendAmountContentTest { } @Test - fun whenFundingSourceSwitching_sourceAndContinueButtonsShouldBeDisabled() { + fun whenFundingSourceLoading_sourceAndContinueButtonsShouldBeDisabled() { composeTestRule.setContent { SendAmountContent( nodeLifecycleState = nodeLifecycleState, uiState = uiState.copy( isAmountInputValid = true, canSwitchFundingSource = true, - isSwitchingFundingSource = true, + isFundingSourceLoading = true, ), amountInputViewModel = previewAmountInputViewModel(), ) @@ -123,4 +125,25 @@ class SendAmountContentTest { composeTestRule.onNodeWithTag("AssetButton-switch").assertIsNotEnabled() composeTestRule.onNodeWithTag("ContinueAmount").assertIsNotEnabled() } + + @Test + fun whenContinueLoading_sourceButtonKeepsStyleAndIgnoresClicks() { + var eventTriggered = false + composeTestRule.setContent { + SendAmountContent( + nodeLifecycleState = nodeLifecycleState, + uiState = uiState.copy( + isAmountInputValid = true, + canSwitchFundingSource = true, + isLoading = true, + ), + amountInputViewModel = previewAmountInputViewModel(), + onClickPayMethod = { eventTriggered = true }, + ) + } + + composeTestRule.onNodeWithTag("AssetButton-switch").assertHasNoClickAction() + composeTestRule.onNodeWithTag("ContinueAmount").assertIsNotEnabled() + assertFalse(eventTriggered) + } } diff --git a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt index 247909b49..d2b76f98c 100644 --- a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -88,7 +88,6 @@ fun ConnectionIssuesView( modifier = Modifier.fillMaxWidth() ) { GradientCircularProgressIndicator( - strokeWidth = 1.dp, modifier = Modifier.size(32.dp) ) } diff --git a/app/src/main/java/to/bitkit/ui/components/GradientCircularProgressIndicator.kt b/app/src/main/java/to/bitkit/ui/components/GradientCircularProgressIndicator.kt index b1be5f08e..e8ac6f508 100644 --- a/app/src/main/java/to/bitkit/ui/components/GradientCircularProgressIndicator.kt +++ b/app/src/main/java/to/bitkit/ui/components/GradientCircularProgressIndicator.kt @@ -24,6 +24,7 @@ import to.bitkit.ui.theme.Colors fun GradientCircularProgressIndicator( modifier: Modifier = Modifier, strokeWidth: Dp = 1.dp, + tint: Color = Colors.White, ) { val infiniteTransition = rememberInfiniteTransition(label = "rotation") val angle by infiniteTransition.animateFloat( @@ -35,7 +36,7 @@ fun GradientCircularProgressIndicator( label = "rotation" ) - val brush = remember { Brush.sweepGradient(listOf(Color.Transparent, Colors.White)) } + val brush = remember { Brush.sweepGradient(listOf(Color.Transparent, tint)) } val strokeWidthPx = with(LocalDensity.current) { strokeWidth.toPx() } val stroke = remember(strokeWidthPx) { Stroke(width = strokeWidthPx, cap = StrokeCap.Round) } diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt index 834cde45f..5ce80f223 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt @@ -1,6 +1,8 @@ package to.bitkit.ui.components import androidx.annotation.DrawableRes +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -20,9 +21,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R -import to.bitkit.ui.shared.modifiers.alphaFeedback +import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.util.primaryButtonStyle -import to.bitkit.ui.theme.AppButtonDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -34,6 +34,7 @@ fun NumberPadActionButton( color: Color = Colors.Brand, enabled: Boolean = true, isLoading: Boolean = false, + clickable: Boolean = true, @DrawableRes icon: Int? = null, ) { val contentPadding = PaddingValues(horizontal = 8.dp, vertical = 5.dp) @@ -41,47 +42,39 @@ fun NumberPadActionButton( val buttonShape = RoundedCornerShape(8.dp) if (enabled || isLoading) { - Button( - onClick = onClick, - enabled = enabled && !isLoading, - colors = AppButtonDefaults.primaryColors.copy( - containerColor = Color.Transparent, - disabledContainerColor = Color.Transparent - ), - contentPadding = contentPadding, - shape = buttonShape, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .requiredHeight(height) .primaryButtonStyle( - isEnabled = enabled && !isLoading, + isEnabled = enabled || isLoading, shape = buttonShape, ) - .alphaFeedback(enabled = enabled && !isLoading) + .animateContentSize(animationSpec = tween(durationMillis = 200)) + .clickableAlpha(enabled = enabled && clickable && !isLoading, onClick = onClick) + .padding(contentPadding) ) { if (isLoading) { GradientCircularProgressIndicator( strokeWidth = 2.dp, + tint = color, + modifier = Modifier + .size(16.dp) + .padding(3.dp) + ) + } else if (icon != null) { + Icon( + painter = painterResource(icon), + contentDescription = text, + tint = color, modifier = Modifier.size(16.dp) ) - } else { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (icon != null) { - Icon( - painter = painterResource(icon), - contentDescription = text, - tint = color, - modifier = Modifier.size(16.dp) - ) - } - Caption13Up( - text = text, - color = color, - ) - } } + Caption13Up( + text = text, + color = color, + ) } } else { Row( diff --git a/app/src/main/java/to/bitkit/ui/components/SendCell.kt b/app/src/main/java/to/bitkit/ui/components/SendCell.kt index 0d8f36376..5a856fc28 100644 --- a/app/src/main/java/to/bitkit/ui/components/SendCell.kt +++ b/app/src/main/java/to/bitkit/ui/components/SendCell.kt @@ -26,7 +26,7 @@ fun SendCell( } } -@Preview(showSystemUi = true) +@Preview @Composable private fun Preview() { AppThemeSurface { diff --git a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt index 6ae8c7297..ede0bd9c6 100644 --- a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt +++ b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt @@ -83,6 +83,7 @@ fun SwipeToConfirm( val panX = remember { Animatable(0f) } val loadingOpacity = remember { Animatable(0f) } + val contentAlpha = if (enabled || loading) 1f else 0.5f LaunchedEffect(loading) { loadingOpacity.animateTo( @@ -110,12 +111,12 @@ fun SwipeToConfirm( isEnabled = !loading, shape = CircleShape, ) - .alpha(if (enabled || loading) 1f else 0.5f) .padding(Padding) ) { Box( modifier = Modifier .fillMaxSize() + .alpha(contentAlpha) .onSizeChanged { size -> swiperWidth = size.width.toFloat() } diff --git a/app/src/main/java/to/bitkit/ui/components/SyncNodeView.kt b/app/src/main/java/to/bitkit/ui/components/SyncNodeView.kt index 234a3f11b..57bdd527e 100644 --- a/app/src/main/java/to/bitkit/ui/components/SyncNodeView.kt +++ b/app/src/main/java/to/bitkit/ui/components/SyncNodeView.kt @@ -56,7 +56,6 @@ fun SyncNodeView(modifier: Modifier) { VerticalSpacer(45.dp) GradientCircularProgressIndicator( - strokeWidth = 1.dp, modifier = Modifier.size(24.dp) ) 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 1b40d76b0..72a2ffd7d 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 @@ -328,7 +328,7 @@ private fun SendAmountNodeRunning( PrimaryButton( text = stringResource(R.string.common__continue), - enabled = uiState.isAmountInputValid && !uiState.isSwitchingFundingSource, + enabled = uiState.isAmountInputValid && !uiState.isFundingSourceLoading, isLoading = uiState.isLoading, onClick = onContinue, modifier = Modifier.testTag("ContinueAmount") @@ -364,8 +364,9 @@ private fun PaymentMethodButton( }, icon = if (uiState.canSwitchFundingSource) R.drawable.ic_transfer else null, onClick = onClick, - enabled = uiState.canSwitchFundingSource && !uiState.isLoading, - isLoading = uiState.isSwitchingFundingSource, + enabled = uiState.canSwitchFundingSource, + isLoading = uiState.isFundingSourceLoading, + clickable = !uiState.isLoading, modifier = Modifier .height(28.dp) .testTag("AssetButton-$testId") diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index e1838efd1..cb38d9a41 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -1,5 +1,7 @@ package to.bitkit.ui.screens.wallets.send +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -19,7 +21,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -72,6 +73,7 @@ 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.GradientCircularProgressIndicator import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar @@ -93,11 +95,12 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.rememberBiometricAuthSupported import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.LnurlParams +import to.bitkit.viewmodels.OnchainFeeUi import to.bitkit.viewmodels.SanityWarning import to.bitkit.viewmodels.SendEvent -import to.bitkit.viewmodels.SendFee import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds private val EXPIRY_REFRESH_INTERVAL = 60.seconds @@ -176,7 +179,7 @@ fun SendConfirmScreen( onSwipeToConfirm = { scope.launch { isLoading = true - delay(300) + delay(300.milliseconds) onEvent(SendEvent.SwipeToPay) } }, @@ -291,6 +294,7 @@ private fun ContentRunning( var showDetails by rememberSaveable { mutableStateOf(initialShowDetails) } val swipeProgress = remember { mutableFloatStateOf(0f) } val isLnurlPay = uiState.lnurl is LnurlParams.LnurlPay + val isHardwareFeeLoading = uiState.hardwareWalletId != null && uiState.onchainFeeUi.isLoading val accentColor = when (uiState.payMethod) { SendMethod.ONCHAIN -> Colors.Brand @@ -320,7 +324,11 @@ private fun ContentRunning( } else if (showDetails) { when (uiState.payMethod) { SendMethod.ONCHAIN -> { - OnChainDetails(uiState = uiState, onEvent = onEvent) + OnChainDetails( + uiState = uiState, + interactionsEnabled = !isHardwareFeeLoading, + onEvent = onEvent, + ) VerticalSpacer(16.dp) TagsSection(uiState, onClickTag, onClickAddTag) } @@ -389,7 +397,9 @@ private fun ContentRunning( SwipeToConfirm( text = stringResource(R.string.wallet__send_swipe), color = accentColor, - enabled = uiState.isAmountInputValid && !uiState.isSwitchingFundingSource, + enabled = uiState.isAmountInputValid && + !uiState.isFundingSourceLoading && + !isHardwareFeeLoading, loading = isLoading, confirmed = isLoading, progress = swipeProgress, @@ -435,7 +445,7 @@ private fun TagsSection( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - uiState.selectedTags.map { tagText -> + uiState.selectedTags.forEach { tagText -> TagButton( text = tagText, displayIconClose = true, @@ -491,10 +501,11 @@ private fun AddTagButton( @Composable private fun OnChainDetails( uiState: SendUiState, + interactionsEnabled: Boolean, onEvent: (SendEvent) -> Unit, modifier: Modifier = Modifier, ) { - val fee = remember(uiState.speed) { FeeRate.fromSpeed(uiState.speed) } + val feeUi = uiState.onchainFeeUi Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier.fillMaxWidth() @@ -515,10 +526,9 @@ private fun OnChainDetails( }, color = if (uiState.hardwareWalletId != null) Colors.Blue else Colors.Brand, enabled = uiState.canSwitchFundingSource, - isLoading = uiState.isSwitchingFundingSource, - icon = R.drawable.ic_transfer.takeIf { - uiState.canSwitchFundingSource - }, + isLoading = uiState.isFundingSourceLoading, + clickable = interactionsEnabled, + icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchFundingSource }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") ) @@ -552,34 +562,47 @@ private fun OnChainDetails( modifier = Modifier .weight(1f) .fillMaxHeight() - .clickableAlpha { onEvent(SendEvent.SpeedAndFee) } + .clickableAlpha(enabled = interactionsEnabled) { onEvent(SendEvent.SpeedAndFee) } ) { SendCell(caption = stringResource(R.string.wallet__send_fee_and_speed)) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon( - painterResource(fee.icon), - contentDescription = null, - tint = fee.color, - modifier = Modifier.size(16.dp) - ) - (uiState.fee as? SendFee.OnChain)?.value - ?.takeIf { it > 0 } - ?.let { feeSat -> - val feeText = let { - val prefix = stringResource(fee.title) - val value = rememberMoneyText(feeSat, showSymbol = true) - "$prefix ($value)" - } - BodySSB( - text = feeText.withAccent(accentColor = Colors.White), - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) + if (feeUi.isLoading) { + GradientCircularProgressIndicator( + tint = feeUi.rate.color, + modifier = Modifier + .size(16.dp) + .padding(3.dp), + ) + } else { + Icon( + painterResource(feeUi.rate.icon), + contentDescription = null, + tint = feeUi.rate.color, + modifier = Modifier.size(16.dp) + ) + } + val feeTextModifier = Modifier.animateContentSize(animationSpec = tween(200)) + if (feeUi.sats == null) { + BodySSB( + text = stringResource(feeUi.rate.title), + modifier = feeTextModifier + ) + } else { + val feeText = let { + val prefix = stringResource(feeUi.rate.title) + val value = rememberMoneyText(feeUi.sats, showSymbol = true) + "$prefix ($value)" } - ?: CircularProgressIndicator(Modifier.size(14.dp), Colors.White64, 2.dp) + BodySSB( + text = feeText.withAccent(accentColor = Colors.White), + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = feeTextModifier + ) + } Icon( painterResource(R.drawable.ic_pencil_simple), contentDescription = null, @@ -602,7 +625,7 @@ private fun OnChainDetails( tint = Colors.Brand, modifier = Modifier.size(16.dp) ) - BodySSB(stringResource(fee.description)) + BodySSB(stringResource(feeUi.rate.description)) } } } @@ -642,7 +665,7 @@ private fun LightningDetails( text = stringResource(R.string.wallet__spending__title), color = Colors.Purple, enabled = uiState.canSwitchFundingSource, - isLoading = uiState.isSwitchingFundingSource, + isLoading = uiState.isFundingSourceLoading, icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchFundingSource }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") @@ -690,7 +713,7 @@ private fun LightningDetails( tint = Colors.Purple, modifier = Modifier.size(16.dp) ) - (uiState.fee as? SendFee.Lightning)?.value + uiState.lightningFeeSats ?.takeIf { it > 0 } ?.let { feeSat -> val feeText = let { @@ -818,7 +841,7 @@ private fun LnurlPayDetails( tint = Colors.Purple, modifier = Modifier.size(16.dp) ) - (uiState.fee as? SendFee.Lightning)?.value + uiState.lightningFeeSats ?.takeIf { it > 0 } ?.let { feeSat -> val feeText = let { @@ -841,7 +864,6 @@ private fun LnurlPayDetails( } } -@Suppress("SpellCheckingInspection") private fun sendUiState() = SendUiState( amount = 2_345u, address = "bcrt1qkgfgyxyqhvkdqh04sklnzxphmcds6vft6y7h0r", @@ -867,8 +889,11 @@ private fun PreviewOnChain() { Content( uiState = sendUiState().copy( selectedTags = persistentListOf("car", "house", "uber"), - fee = SendFee.OnChain(1_234), speed = TransactionSpeed.Medium, + onchainFeeUi = OnchainFeeUi( + rate = FeeRate.NORMAL, + sats = 1_234, + ), ), isNodeRunning = true, isLoading = false, @@ -888,8 +913,11 @@ private fun PreviewOnChainDetails() { Content( uiState = sendUiState().copy( selectedTags = persistentListOf("car", "house", "uber"), - fee = SendFee.OnChain(1_234), speed = TransactionSpeed.Medium, + onchainFeeUi = OnchainFeeUi( + rate = FeeRate.NORMAL, + sats = 1_234, + ), ), isNodeRunning = true, isLoading = false, @@ -912,7 +940,7 @@ private fun PreviewLightningDetails() { amount = 6_543u, payMethod = SendMethod.LIGHTNING, selectedTags = persistentListOf("coffee"), - fee = SendFee.Lightning(43), + lightningFeeSats = 43, ), isNodeRunning = true, isLoading = false, @@ -934,8 +962,11 @@ private fun PreviewOnChainLongFeeSmallScreen() { uiState = sendUiState().copy( amount = 2_345_678u, selectedTags = persistentListOf("car", "house", "uber"), - fee = SendFee.OnChain(654_321), speed = TransactionSpeed.Custom(12_345u), + onchainFeeUi = OnchainFeeUi( + rate = FeeRate.CUSTOM, + sats = 654_321, + ), ), isNodeRunning = true, isLoading = false, @@ -954,7 +985,7 @@ private fun PreviewOnChainFeeLoading() { Content( uiState = sendUiState().copy( selectedTags = persistentListOf("car", "house", "uber"), - fee = null, + onchainFeeUi = OnchainFeeUi(isLoading = true), ), isNodeRunning = true, isLoading = false, @@ -976,7 +1007,7 @@ private fun PreviewLightning() { amount = 6_543u, payMethod = SendMethod.LIGHTNING, selectedTags = persistentListOf(), - fee = SendFee.Lightning(43), + lightningFeeSats = 43, ), isNodeRunning = true, isLoading = false, @@ -1027,7 +1058,7 @@ private fun PreviewLnurlDetails() { uiState = sendUiState().copy( amount = 5_000u, payMethod = SendMethod.LIGHTNING, - fee = SendFee.Lightning(12), + lightningFeeSats = 12, lnurl = LnurlParams.LnurlPay( data = LnurlPayData( uri = "veryLongLnurlPayUri12345677890123456789012345678901234567890", diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt index ecf43247d..0a42cfab7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt @@ -51,8 +51,8 @@ class SendFeeViewModel @Inject constructor( fun init(sendUiState: SendUiState) { this.sendUiState = sendUiState this.maxFee = getFeeLimit() - val selected = FeeRate.fromSpeed(sendUiState.speed) - val fees = sendUiState.fees + val selected = sendUiState.onchainFeeUi.rate + val fees = sendUiState.onchainFeeUi.estimates viewModelScope.launch { val custom = when (val speed = sendUiState.speed) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index cb8980359..91d20b209 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import android.nfc.NfcAdapter import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -299,7 +300,6 @@ class AppViewModel @Inject constructor( SendEvent.PaymentMethodSwitch -> { if (fundingSourceSwitchPending) return fundingSourceSwitchPending = true - _sendUiState.update { it.copy(isSwitchingFundingSource = true) } } else -> Unit @@ -1632,7 +1632,7 @@ class AppViewModel @Inject constructor( onPaymentMethodSwitch() } finally { fundingSourceSwitchPending = false - _sendUiState.update { state -> state.copy(isSwitchingFundingSource = false) } + _sendUiState.update { state -> state.copy(isFundingSourceLoading = false) } } is SendEvent.CoinSelectionContinue -> onCoinSelectionContinue(it.utxos) @@ -1640,9 +1640,9 @@ class AppViewModel @Inject constructor( is SendEvent.CommentChange -> onCommentChange(it.value) SendEvent.SpeedAndFee -> { - if (_sendUiState.value.fees.isEmpty()) { + if (_sendUiState.value.onchainFeeUi.estimates.isEmpty()) { viewModelScope.launch { - refreshFeeEstimates() + refreshOnchainFeeUi() setSendEffect(SendEffect.NavigateToFee) } } else { @@ -2098,48 +2098,41 @@ class AppViewModel @Inject constructor( } fun setTransactionSpeed(speed: TransactionSpeed) { + onchainSendRefreshJob?.cancel() + val previous = _sendUiState.value + _sendUiState.update { + it.copy( + payMethod = SendMethod.ONCHAIN, + speed = speed, + onchainFeeUi = it.onchainFeeUi.copy(isLoading = true), + ) + } + setSendEffect(SendEffect.PopBack(SendRoute.Confirm)) viewModelScope.launch { - val state = _sendUiState.value val shouldResetUtxos = when (settingsStore.data.first().coinSelectAuto) { true -> { - val currentSatsPerVByte = state.feeRates?.getSatsPerVByteFor(state.speed) - val newSatsPerVByte = state.feeRates?.getSatsPerVByteFor(speed) + val currentSatsPerVByte = previous.feeRates?.getSatsPerVByteFor(previous.speed) + val newSatsPerVByte = previous.feeRates?.getSatsPerVByteFor(speed) currentSatsPerVByte != newSatsPerVByte } else -> false } - val wasHardwareMax = state.hardwareWalletId != null && - state.amount > 0uL && - state.amount == state.hardwareAvailableSats - val hardwareAvailableSats = state.hardwareWalletId?.let { walletId -> - hardwareMaxSpendable(walletId, state.address, speed) - } ?: state.hardwareAvailableSats + val wasHardwareMax = previous.hardwareWalletId != null && + previous.amount > 0uL && + previous.amount == previous.hardwareAvailableSats + val hardwareAvailableSats = previous.hardwareWalletId?.let { walletId -> + hardwareMaxSpendable(walletId, previous.address, speed) + } ?: previous.hardwareAvailableSats _sendUiState.update { it.copy( - payMethod = SendMethod.ONCHAIN, - speed = speed, amount = if (wasHardwareMax) hardwareAvailableSats else it.amount, hardwareAvailableSats = hardwareAvailableSats, selectedUtxos = if (shouldResetUtxos) null else it.selectedUtxos, ) } - val fee = when (speed is TransactionSpeed.Custom) { - true -> getFeeEstimate(speed) - else -> if (state.hardwareWalletId != null) { - getFeeEstimate(speed) - } else { - state.fees.getOrDefault(FeeRate.fromSpeed(speed), 0) - } - } - _sendUiState.update { - it.copy( - fee = SendFee.OnChain(fee), - ) - } updateCanSwitchWallet() - refreshOnchainSendIfNeeded() - setSendEffect(SendEffect.PopBack(SendRoute.Confirm)) + refreshOnchainSendIfNeeded() ?: updateOnchainFeeUi { it.copy(isLoading = false) } } } @@ -2169,7 +2162,11 @@ class AppViewModel @Inject constructor( onchainSendRefreshJob?.cancel() val selected = current.selectedFundingSource() val selectedIndex = sources.indexOf(selected).takeIf { it >= 0 } ?: 0 - when (val nextSource = sources[(selectedIndex + 1) % sources.size]) { + val nextSource = sources[(selectedIndex + 1) % sources.size] + _sendUiState.update { + it.copy(isFundingSourceLoading = nextSource is SendFundingSource.Hardware) + } + when (nextSource) { SendFundingSource.Spending -> { _sendUiState.update { it.copy( @@ -2177,9 +2174,10 @@ class AppViewModel @Inject constructor( hardwareWalletId = null, hardwareWalletName = null, hardwareAvailableSats = 0uL, - fee = SendFee.Lightning(0), + lightningFeeSats = 0, selectedUtxos = null, confirmedWarnings = persistentListOf(), + isAmountInputValid = validateAmount(it.amount, SendMethod.LIGHTNING), ) } estimateLightningRoutingFeesIfNeeded() @@ -2192,7 +2190,6 @@ class AppViewModel @Inject constructor( hardwareWalletId = null, hardwareWalletName = null, hardwareAvailableSats = 0uL, - fee = null, selectedUtxos = null, confirmedWarnings = persistentListOf(), ) @@ -2220,9 +2217,9 @@ class AppViewModel @Inject constructor( hardwareWalletName = walletName, hardwareAvailableSats = initialAvailable, isAmountInputValid = it.amount > Defaults.dustLimit.toULong() && it.amount <= initialAvailable, - fee = null, selectedUtxos = null, confirmedWarnings = persistentListOf(), + onchainFeeUi = it.onchainFeeUi.copy(isLoading = true), ) } val available = hardwareMaxSpendable(walletId, current.address, current.speed) @@ -2264,7 +2261,7 @@ class AppViewModel @Inject constructor( hardwareWalletId = null, hardwareWalletName = null, hardwareAvailableSats = 0uL, - fee = SendFee.Lightning(0), + lightningFeeSats = 0, isAmountInputValid = validateAmount(it.amount, SendMethod.LIGHTNING), confirmedWarnings = persistentListOf(), ) @@ -2339,7 +2336,13 @@ class AppViewModel @Inject constructor( toast(error) return false } - _sendUiState.update { it.copy(fee = SendFee.OnChain(miningFeeSats.toLong())) } + updateOnchainFeeUi { + it.copy( + rate = FeeRate.fromSpeed(state.speed), + sats = miningFeeSats.toLong(), + isLoading = false, + ) + } return true } @@ -2347,7 +2350,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(selectedUtxos = utxos.toImmutableList()) } - refreshFeeEstimates() + refreshOnchainFeeUi() setSendEffect(SendEffect.NavigateToConfirm) } @@ -3284,7 +3287,7 @@ class AppViewModel @Inject constructor( if (_sendUiState.value.payMethod != SendMethod.ONCHAIN) return val totalFee = if (_sendUiState.value.hardwareWalletId != null) { - (_sendUiState.value.fee as? SendFee.OnChain)?.value?.toULong() ?: return + _sendUiState.value.onchainFeeUi.sats?.toULong() ?: return } else { lightningRepo.calculateTotalFee( amountSats = amountSats, @@ -3716,6 +3719,7 @@ class AppViewModel @Inject constructor( return null } + updateOnchainFeeUi { it.copy(isLoading = true) } onchainSendRefreshJob?.cancel() val job = viewModelScope.launch(bgDispatcher, start = CoroutineStart.LAZY) { // preselect utxos for deterministic fee estimation @@ -3735,7 +3739,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(selectedUtxos = utxos?.toImmutableList()) } } } - refreshFeeEstimates() + refreshOnchainFeeUi() } onchainSendRefreshJob = job job.invokeOnCompletion { @@ -3745,8 +3749,9 @@ class AppViewModel @Inject constructor( return job } - private suspend fun refreshFeeEstimates() = withContext(bgDispatcher) { + private suspend fun refreshOnchainFeeUi() = withContext(bgDispatcher) { val currentState = _sendUiState.value + updateOnchainFeeUi { it.copy(isLoading = true) } val speeds = listOf( TransactionSpeed.Fast, @@ -3758,30 +3763,33 @@ class AppViewModel @Inject constructor( } ) - var currentFee = 0L - val feesMap = coroutineScope { + val estimates = coroutineScope { speeds.map { speed -> async { val rate = FeeRate.fromSpeed(speed) val fee = if (currentState.feeRates?.getSatsPerVByteFor(speed) != 0u) getFeeEstimate(speed) else 0 - - if (speed == currentState.speed) { - currentFee = fee - } - rate to fee } }.awaitAll().toMap() } + val rate = FeeRate.fromSpeed(currentState.speed) - _sendUiState.update { - it.copy( - fees = feesMap.toImmutableMap(), - fee = if (it.payMethod == SendMethod.ONCHAIN) SendFee.OnChain(currentFee) else it.fee, + updateOnchainFeeUi { + OnchainFeeUi( + rate = rate, + sats = estimates[rate]?.takeIf { sats -> sats > 0 }, + estimates = estimates.toImmutableMap(), + isLoading = false, ) } } + private fun updateOnchainFeeUi(transform: (OnchainFeeUi) -> OnchainFeeUi) { + _sendUiState.update { + it.copy(onchainFeeUi = transform(it.onchainFeeUi)) + } + } + private suspend fun estimateLightningRoutingFeesIfNeeded() { val currentState = _sendUiState.value if (currentState.payMethod != SendMethod.LIGHTNING) return @@ -3796,7 +3804,7 @@ class AppViewModel @Inject constructor( feeResult.onSuccess { fee -> _sendUiState.update { it.copy( - fee = SendFee.Lightning(fee.toLong()), + lightningFeeSats = fee.toLong(), lastLightningFee = fee.toLong(), ) } @@ -3874,6 +3882,7 @@ class AppViewModel @Inject constructor( SendUiState( speed = speed, feeRates = rates, + onchainFeeUi = OnchainFeeUi(rate = FeeRate.fromSpeed(speed)), contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest, hardwareWalletId = hardwareWalletId, @@ -3883,7 +3892,7 @@ class AppViewModel @Inject constructor( hardwareAvailableSats = hardwareWalletId?.let { walletId -> hardwareEstimatedAvailable(walletId, speed, rates) } ?: 0uL, - isSwitchingFundingSource = fundingSourceSwitchPending, + isFundingSourceLoading = it.isFundingSourceLoading, ) } } @@ -4710,7 +4719,7 @@ data class SendUiState( val isUnified: Boolean = false, val canSwitchWallet: Boolean = false, val canSwitchFundingSource: Boolean = false, - val isSwitchingFundingSource: Boolean = false, + val isFundingSourceLoading: Boolean = false, val payMethod: SendMethod = SendMethod.ONCHAIN, val selectedTags: ImmutableList = persistentListOf(), val decodedInvoice: LightningInvoice? = null, @@ -4723,8 +4732,8 @@ data class SendUiState( val speed: TransactionSpeed = TransactionSpeed.default(), val comment: String = "", val feeRates: FeeRates? = null, - val fee: SendFee? = null, - val fees: ImmutableMap = persistentMapOf(), + val onchainFeeUi: OnchainFeeUi = OnchainFeeUi(), + val lightningFeeSats: Long? = null, val estimatedRoutingFee: ULong = 0uL, val lastLightningFee: Long = 0L, val contactPaymentProfile: PubkyProfile? = null, @@ -4734,6 +4743,14 @@ data class SendUiState( val hardwareAvailableSats: ULong = 0uL, ) +@Immutable +data class OnchainFeeUi( + val rate: FeeRate = FeeRate.fromSpeed(TransactionSpeed.default()), + val sats: Long? = null, + val estimates: ImmutableMap = persistentMapOf(), + val isLoading: Boolean = false, +) + enum class SanityWarning(@StringRes val message: Int, val testTag: String) { VALUE_OVER_100_USD(R.string.wallet__send_dialog1, "SendDialog1"), OVER_HALF_BALANCE(R.string.wallet__send_dialog2, "SendDialog2"), @@ -4742,11 +4759,6 @@ enum class SanityWarning(@StringRes val message: Int, val testTag: String) { // TODO SendDialog5 https://github.com/synonymdev/bitkit/blob/master/src/screens/Wallets/Send/ReviewAndSend.tsx#L457-L466 } -sealed class SendFee(open val value: Long) { - data class OnChain(override val value: Long) : SendFee(value) - data class Lightning(override val value: Long) : SendFee(value) -} - enum class SendMethod { ONCHAIN, LIGHTNING } private sealed interface SendFundingSource { diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt index 4d2529018..12afee4b7 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt @@ -11,7 +11,6 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.BalanceState @@ -22,6 +21,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.viewmodels.OnchainFeeUi import to.bitkit.viewmodels.SendUiState import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -42,7 +42,7 @@ class SendFeeViewModelTest : BaseUnitTest() { fun setUp() { whenever(context.getString(any())).thenReturn("text") - wheneverBlocking { lightningRepo.calculateTotalFee(any(), any(), any(), any(), any()) } + whenever { lightningRepo.calculateTotalFee(any(), any(), any(), any(), any()) } .thenReturn(Result.success(fee)) whenever(walletRepo.balanceState) @@ -141,6 +141,6 @@ class SendFeeViewModelTest : BaseUnitTest() { address = address, speed = TransactionSpeed.Medium, feeRates = FeeRates(fast = 10u, mid = 5u, slow = 2u), - fees = fees.toImmutableMap(), + onchainFeeUi = OnchainFeeUi(estimates = fees.toImmutableMap()), ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 275ede3cc..c4af4a99c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -16,6 +16,7 @@ import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -37,6 +38,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any @@ -66,6 +68,8 @@ import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.FeeRate import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails @@ -77,6 +81,7 @@ import to.bitkit.models.SamRockSetupRequest import to.bitkit.models.SendFailureDetails import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransportType +import to.bitkit.models.USD import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -133,6 +138,7 @@ import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError import to.bitkit.utils.timedsheets.TimedSheetManager +import java.math.BigDecimal import java.net.URLEncoder import java.nio.charset.StandardCharsets import kotlin.test.assertEquals @@ -1990,7 +1996,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `switch from onchain to lightning sets fee to Lightning zero`() = test { + fun `switch from onchain to lightning resets lightning fee`() = test { balanceState.value = BalanceState( maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u, @@ -1999,10 +2005,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() sut.setSendEvent(SendEvent.PaymentMethodSwitch) + assertFalse(sut.sendUiState.value.isFundingSourceLoading) advanceUntilIdle() assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) - assertEquals(SendFee.Lightning(0), sut.sendUiState.value.fee) + assertEquals(0, sut.sendUiState.value.lightningFeeSats) } @Test @@ -2054,7 +2061,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PaymentMethodSwitch) exactAvailableStarted.await() - assertTrue(sut.sendUiState.value.isSwitchingFundingSource) + assertTrue(sut.sendUiState.value.isFundingSourceLoading) + assertTrue(sut.sendUiState.value.onchainFeeUi.isLoading) assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) assertEquals("Trezor", sut.sendUiState.value.hardwareWalletName) assertEquals(46_400uL, sut.sendUiState.value.hardwareAvailableSats) @@ -2063,17 +2071,56 @@ class AppViewModelSendFlowTest : BaseUnitTest() { finishExactAvailable.complete(Unit) feeEstimateStarted.await() - assertTrue(sut.sendUiState.value.isSwitchingFundingSource) + assertTrue(sut.sendUiState.value.isFundingSourceLoading) finishFeeEstimate.complete(Unit) advanceUntilIdle() - assertFalse(sut.sendUiState.value.isSwitchingFundingSource) + assertFalse(sut.sendUiState.value.isFundingSourceLoading) assertEquals(48_000uL, sut.sendUiState.value.hardwareAvailableSats) assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) verify(hwWalletRepo, times(1)).maxSpendableFunding(any(), any(), any()) } + @Test + fun `source switch from hw wallet keeps valid state while refreshing`() = test { + val feeEstimateStarted = CompletableDeferred() + val finishFeeEstimate = CompletableDeferred() + hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = 50_000uL)) + whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } + .doSuspendableAnswer { + feeEstimateStarted.complete(Unit) + finishFeeEstimate.await() + Result.success(100uL) + } + balanceState.value = BalanceState(maxSendOnchainSats = 100_000uL) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000uL, + isAmountInputValid = true, + hardwareWalletId = HARDWARE_WALLET_ID, + hardwareWalletName = "Trezor", + hardwareAvailableSats = 50_000uL, + payMethod = SendMethod.ONCHAIN, + feeRates = FeeRates(fast = 5u, mid = 3u, slow = 1u), + ) + ) + + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + feeEstimateStarted.await() + + assertNull(sut.sendUiState.value.hardwareWalletId) + assertFalse(sut.sendUiState.value.isFundingSourceLoading) + assertTrue(sut.sendUiState.value.isAmountInputValid) + + finishFeeEstimate.complete(Unit) + advanceUntilIdle() + + assertFalse(sut.sendUiState.value.isFundingSourceLoading) + assertTrue(sut.sendUiState.value.isAmountInputValid) + } + @Test fun `invalid funding source cannot start confirmation`() = test { setSendState( @@ -2093,6 +2140,42 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.sendUiState.value.shouldConfirmPay) } + @Test + fun `missing hardware fee does not block confirmation`() = test { + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( + Result.success( + ConvertedAmount( + value = BigDecimal.ZERO, + formatted = "0.00", + symbol = "$", + currency = USD, + flag = "", + sats = 1_000, + ) + ) + ) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000uL, + isAmountInputValid = true, + hardwareWalletId = HARDWARE_WALLET_ID, + hardwareAvailableSats = 100_000uL, + payMethod = SendMethod.ONCHAIN, + onchainFeeUi = OnchainFeeUi(sats = null), + ) + ) + + sut.setSendEvent(SendEvent.SwipeToPay) + advanceUntilIdle() + + assertTrue(sut.sendUiState.value.shouldConfirmPay) + sut.sendEffect.test { + sut.setSendEvent(SendEvent.PayConfirmed) + assertEquals(SendEffect.NavigateToHardwareSign, awaitItem()) + } + } + @Test fun `fixed lightning invoice has valid amount`() = test { val bolt11 = "lnbcrt1fixedamount" @@ -4299,16 +4382,123 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `refreshFeeEstimates preserves lightning fee when payMethod is LIGHTNING`() = test { - val lightningFee = SendFee.Lightning(42) - setUnifiedState(amount = 1000u, payMethod = SendMethod.LIGHTNING, fee = lightningFee) + fun `refreshing onchain fees preserves lightning fee`() = test { + setUnifiedState(amount = 1000u, payMethod = SendMethod.LIGHTNING, lightningFeeSats = 42) advanceUntilIdle() sut.setSendEvent(SendEvent.SpeedAndFee) advanceUntilIdle() - val currentFee = sut.sendUiState.value.fee - assertEquals(lightningFee, currentFee) + assertEquals(42, sut.sendUiState.value.lightningFeeSats) + } + + @Test + fun `selecting speed pops before hardware max spendable finishes`() = test { + val maxStarted = CompletableDeferred() + val finishMax = CompletableDeferred() + whenever { hwWalletRepo.maxSpendableFunding(any(), any(), any()) } + .doSuspendableAnswer { + maxStarted.complete(Unit) + finishMax.await() + Result.success(48_000uL) + } + val previousFeeUi = OnchainFeeUi( + rate = FeeRate.NORMAL, + sats = 141, + estimates = persistentMapOf(FeeRate.NORMAL to 141L), + ) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000u, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + hardwareWalletId = HARDWARE_WALLET_ID, + hardwareWalletName = "Trezor", + hardwareAvailableSats = 50_000uL, + feeRates = FeeRates(fast = 5u, mid = 3u, slow = 1u), + onchainFeeUi = previousFeeUi, + ) + ) + + sut.sendEffect.test { + sut.setTransactionSpeed(TransactionSpeed.Fast) + assertEquals(SendEffect.PopBack(SendRoute.Confirm), awaitItem()) + assertEquals(TransactionSpeed.Fast, sut.sendUiState.value.speed) + assertEquals(previousFeeUi.copy(isLoading = true), sut.sendUiState.value.onchainFeeUi) + maxStarted.await() + finishMax.complete(Unit) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `changing speed resets selected utxos from previous rate`() = test { + whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(200uL)) + whenever { lightningRepo.determineUtxosToSpend(any(), any()) } + .thenReturn(null) + val selectedUtxos = persistentListOf(mock()) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000u, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + feeRates = FeeRates(fast = 5u, mid = 3u, slow = 1u), + selectedUtxos = selectedUtxos, + onchainFeeUi = OnchainFeeUi( + rate = FeeRate.NORMAL, + sats = 141, + estimates = persistentMapOf(FeeRate.NORMAL to 141L), + ), + ) + ) + + sut.setTransactionSpeed(TransactionSpeed.Fast) + advanceUntilIdle() + + assertNull(sut.sendUiState.value.selectedUtxos) + } + + @Test + fun `changing speed keeps previous onchain fee while estimates refresh`() = test { + val feeEstimateStarted = CompletableDeferred() + val finishFeeEstimate = CompletableDeferred() + whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } + .doSuspendableAnswer { + feeEstimateStarted.complete(Unit) + finishFeeEstimate.await() + Result.success(200uL) + } + val previousFeeUi = OnchainFeeUi( + rate = FeeRate.FAST, + sats = 100, + estimates = persistentMapOf(FeeRate.FAST to 100L), + ) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000u, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Fast, + feeRates = FeeRates(fast = 5u, mid = 3u, slow = 1u), + onchainFeeUi = previousFeeUi, + ) + ) + + sut.setTransactionSpeed(TransactionSpeed.Slow) + feeEstimateStarted.await() + + assertEquals(TransactionSpeed.Slow, sut.sendUiState.value.speed) + assertEquals(previousFeeUi.copy(isLoading = true), sut.sendUiState.value.onchainFeeUi) + + finishFeeEstimate.complete(Unit) + advanceUntilIdle() + + assertEquals(FeeRate.SLOW, sut.sendUiState.value.onchainFeeUi.rate) + assertEquals(200, sut.sendUiState.value.onchainFeeUi.sats) + assertFalse(sut.sendUiState.value.onchainFeeUi.isLoading) } @Test @@ -4320,7 +4510,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setUnifiedState( amount = 1000u, payMethod = SendMethod.LIGHTNING, - fee = SendFee.Lightning(42), + lightningFeeSats = 42, lastLightningFee = 42L, ) advanceUntilIdle() @@ -4627,7 +4817,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun setUnifiedState( amount: ULong = 0u, payMethod: SendMethod = SendMethod.LIGHTNING, - fee: SendFee? = null, + lightningFeeSats: Long? = null, lastLightningFee: Long = 0L, ) { setSendState( @@ -4636,7 +4826,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amount = amount, isUnified = true, payMethod = payMethod, - fee = fee, + lightningFeeSats = lightningFeeSats, lastLightningFee = lastLightningFee, confirmedWarnings = persistentListOf(), speed = TransactionSpeed.Medium, diff --git a/changelog.d/next/1195.changed.md b/changelog.d/next/1195.changed.md new file mode 100644 index 000000000..92a0a1cb6 --- /dev/null +++ b/changelog.d/next/1195.changed.md @@ -0,0 +1 @@ +Send now keeps the funding-source button, fee label, and swipe-to-confirm track stable while switching sources, continuing from amount, or refreshing fees.