diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 54edeeef3..a11aeb683 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -25,6 +25,7 @@ import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.UserChannelId +import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher @@ -60,7 +61,8 @@ class LightningRepo @Inject constructor( private val blocktankNotificationsService: BlocktankNotificationsService, private val firebaseMessaging: FirebaseMessaging, private val keychain: Keychain, - private val lnUrlWithdrawService: LnUrlWithdrawService + private val lnUrlWithdrawService: LnUrlWithdrawService, + private val cacheStore: CacheStore, ) { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() @@ -403,7 +405,7 @@ class LightningRepo @Inject constructor( suspend fun createLnurlInvoice( address: String, - amountSatoshis: ULong + amountSatoshis: ULong, ): Result = executeWhenNodeRunning("getLnUrlInvoice") { val invoice = getLnurlInvoice(address, amountSatoshis) Result.success(invoice) @@ -475,7 +477,7 @@ class LightningRepo @Inject constructor( utxosToSpend: List? = null, ): Result = executeWhenNodeRunning("Send on-chain") { - val transactionSpeed = speed ?: settingsStore.data.map { it.defaultTransactionSpeed }.first() + val transactionSpeed = speed ?: settingsStore.data.first().defaultTransactionSpeed val fees = coreService.blocktank.getFees().getOrThrow() val satsPerVByte = fees.getSatsPerVByteFor(transactionSpeed) @@ -541,25 +543,27 @@ class LightningRepo @Inject constructor( } suspend fun calculateTotalFee( - address: Address, amountSats: ULong, + address: Address? = null, speed: TransactionSpeed? = null, utxosToSpend: List? = null, ): Result = withContext(bgDispatcher) { return@withContext try { - val transactionSpeed = speed ?: settingsStore.data.map { it.defaultTransactionSpeed }.first() + val transactionSpeed = speed ?: settingsStore.data.first().defaultTransactionSpeed val satsPerVByte = getFeeRateForSpeed(transactionSpeed).getOrThrow().toUInt() + val addressOrDefault = address ?: cacheStore.data.first().onchainAddress + val fee = lightningService.calculateTotalFee( - address = address, + address = addressOrDefault, amountSats = amountSats, satsPerVByte = satsPerVByte, utxosToSpend = utxosToSpend, ) Result.success(fee) - } catch (e: Throwable) { + } catch (_: Throwable) { val fallbackFee = 1000uL - Logger.error("Estimate fee error, using conservative fallback of $fallbackFee", e, context = TAG) + Logger.warn("Error calculating fee, using fallback of $fallbackFee", context = TAG) Result.success(fallbackFee) } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index eafc7f625..fc42ab961 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -328,6 +328,27 @@ class WalletRepo @Inject constructor( } } + suspend fun getMaxSendAmount(): ULong = withContext(bgDispatcher) { + val totalOnchainSats = balanceState.value.totalOnchainSats + if (totalOnchainSats == 0uL) { + return@withContext 0uL + } + + try { + val minFeeBuffer = 1000uL + val amountSats = (totalOnchainSats - minFeeBuffer).coerceAtLeast(0uL) + val fee = lightningRepo.calculateTotalFee(amountSats).getOrThrow() + val maxSendable = (totalOnchainSats - fee).coerceAtLeast(0uL) + + return@withContext maxSendable + } catch (_: Throwable) { + Logger.debug("Could not calculate max send amount, using as fallback 90% of total", context = TAG) + val fallbackMax = (totalOnchainSats.toDouble() * 0.9).toULong() + return@withContext fallbackMax + } + } + + // Settings suspend fun setShowEmptyState(show: Boolean) { settingsStore.update { it.copy(showEmptyState = show) } @@ -491,35 +512,6 @@ class WalletRepo @Inject constructor( } } - suspend fun deleteActivityById(id: String) = withContext(bgDispatcher) { - runCatching { - coreService.activity.delete(id) - }.onFailure { e -> - Logger.error(msg = "Error deleteActivityById. id:$id", e = e, context = TAG) - } - } - - - suspend fun getOnChainActivityByTxId(txId: String, txType: PaymentType): Result { - return runCatching { - findActivityWithRetry( - paymentHashOrTxId = txId, - txType = txType, - type = ActivityFilter.ONCHAIN - ) ?: return Result.failure(Exception("Activity not found")) - }.onFailure { e -> - Logger.error(msg = "Error getOnChainActivityByTxId. txId:$txId, txType:$txType", e = e, context = TAG) - } - } - - suspend fun updateActivity(id: String, updatedActivity: Activity): Result { - return runCatching { - coreService.activity.update(id, updatedActivity) - }.onFailure { e -> - Logger.error(msg = "Error updateActivity. id:$id, updatedActivity:$updatedActivity", e = e, context = TAG) - } - } - suspend fun attachTagsToActivity( paymentHashOrTxId: String?, type: ActivityFilter, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 68336d992..eb66d0ede 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -142,7 +142,7 @@ import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.BackupsViewModel import to.bitkit.viewmodels.BlocktankViewModel import to.bitkit.viewmodels.CurrencyViewModel -import to.bitkit.viewmodels.ExternalNodeViewModel +import to.bitkit.ui.screens.transfer.external.ExternalNodeViewModel import to.bitkit.viewmodels.MainScreenEffect import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.SendEvent @@ -615,9 +615,13 @@ private fun RootNavHost( ) } composableWithDefaultTransitions { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.ExternalNav) } + val viewModel = hiltViewModel(parentEntry) + ExternalFeeCustomScreen( - onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.popBackStack(inclusive = true) }, + viewModel = viewModel, + onBack = { navController.popBackStack() }, + onClose = { navController.navigateToHome() }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt index 184303bfd..664be8b01 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt @@ -11,16 +11,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -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 import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ui.LocalBalances import to.bitkit.ui.LocalCurrencies @@ -37,7 +33,7 @@ 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.ExternalNodeViewModel +import kotlin.math.min import kotlin.math.roundToLong @Composable @@ -47,9 +43,14 @@ fun ExternalAmountScreen( onBackClick: () -> Unit, onCloseClick: () -> Unit, ) { - ExternalAmountContent( - onContinueClick = { satsAmount -> - viewModel.onAmountContinue(satsAmount) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Content( + amountState = uiState.amount, + onAmountChange = { sats -> viewModel.onAmountChange(sats) }, + onAmountOverride = { sats -> viewModel.onAmountOverride(sats) }, + onContinueClick = { + viewModel.onAmountContinue() onContinue() }, onBackClick = onBackClick, @@ -58,8 +59,11 @@ fun ExternalAmountScreen( } @Composable -private fun ExternalAmountContent( - onContinueClick: (Long) -> Unit = {}, +private fun Content( + amountState: ExternalNodeContract.UiState.Amount = ExternalNodeContract.UiState.Amount(), + onAmountChange: (Long) -> Unit = {}, + onAmountOverride: (Long) -> Unit = {}, + onContinueClick: () -> Unit = {}, onBackClick: () -> Unit = {}, onCloseClick: () -> Unit = {}, ) { @@ -75,10 +79,7 @@ private fun ExternalAmountContent( .fillMaxSize() .imePadding() ) { - var satsAmount by rememberSaveable { mutableLongStateOf(0) } - var overrideSats: Long? by remember { mutableStateOf(null) } - - val availableAmount = LocalBalances.current.totalOnchainSats + val totalOnchainSats = LocalBalances.current.totalOnchainSats Spacer(modifier = Modifier.height(16.dp)) Display(stringResource(R.string.lightning__external_amount__title).withAccent(accentColor = Colors.Purple)) @@ -86,10 +87,9 @@ private fun ExternalAmountContent( AmountInput( primaryDisplay = LocalCurrencies.current.primaryDisplay, - overrideSats = overrideSats, + overrideSats = amountState.overrideSats, ) { sats -> - satsAmount = sats - overrideSats = null + onAmountChange(sats) } Spacer(modifier = Modifier.weight(1f)) @@ -106,7 +106,7 @@ private fun ExternalAmountContent( color = Colors.White64, ) Spacer(modifier = Modifier.height(8.dp)) - MoneySSB(sats = availableAmount.toLong()) + MoneySSB(sats = amountState.max) } Spacer(modifier = Modifier.weight(1f)) UnitButton(color = Colors.Purple) @@ -115,8 +115,9 @@ private fun ExternalAmountContent( text = stringResource(R.string.lightning__spending_amount__quarter), color = Colors.Purple, onClick = { - val quarter = (availableAmount.toDouble() / 4.0).roundToLong() - overrideSats = quarter + val quarterOfTotal = (totalOnchainSats.toDouble() / 4.0).roundToLong() + val cappedQuarter = min(quarterOfTotal, amountState.max) + onAmountOverride(cappedQuarter) }, ) // Max Button @@ -124,8 +125,7 @@ private fun ExternalAmountContent( text = stringResource(R.string.common__max), color = Colors.Purple, onClick = { - val max = (availableAmount.toDouble() * 0.9).toLong() // TODO calc max amount - overrideSats = max + onAmountOverride(amountState.max) }, ) } @@ -134,8 +134,8 @@ private fun ExternalAmountContent( PrimaryButton( text = stringResource(R.string.common__continue), - onClick = { onContinueClick(satsAmount) }, - enabled = satsAmount != 0L, + onClick = { onContinueClick() }, + enabled = amountState.sats != 0L, ) Spacer(modifier = Modifier.height(16.dp)) @@ -143,11 +143,10 @@ private fun ExternalAmountContent( } } -@Preview(showSystemUi = true, showBackground = true) +@Preview(showSystemUi = true) @Composable private fun Preview() { AppThemeSurface { - ExternalAmountContent( - ) + Content() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt index 635995c2c..3a881efc6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt @@ -1,5 +1,8 @@ package to.bitkit.ui.screens.transfer.external +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -14,7 +17,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -41,9 +43,7 @@ import to.bitkit.ui.shared.util.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.ExternalNodeContract -import to.bitkit.viewmodels.ExternalNodeContract.SideEffect -import to.bitkit.viewmodels.ExternalNodeViewModel +import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect @Composable fun ExternalConfirmScreen( @@ -64,7 +64,7 @@ fun ExternalConfirmScreen( } } - ExternalConfirmContent( + Content( uiState = uiState, onConfirm = { viewModel.onConfirm() }, onNetworkFeeClick = onNetworkFeeClick, @@ -74,7 +74,7 @@ fun ExternalConfirmScreen( } @Composable -private fun ExternalConfirmContent( +private fun Content( uiState: ExternalNodeContract.UiState, onConfirm: () -> Unit = {}, onNetworkFeeClick: () -> Unit = {}, @@ -93,9 +93,9 @@ private fun ExternalConfirmContent( .fillMaxSize() .verticalScroll(rememberScrollState()) ) { - val networkFee = 0L // TODO calculate txFee + val networkFee = uiState.networkFee val serviceFee = 0L - val totalFee = uiState.localBalance + networkFee + val totalFee = uiState.amount.sats + networkFee Spacer(modifier = Modifier.height(16.dp)) Display(text = stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) @@ -117,18 +117,19 @@ private fun ExternalConfirmContent( color = Colors.White64, ) Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - MoneySSB(sats = networkFee) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - painterResource(R.drawable.ic_pencil_simple), - contentDescription = null, - tint = Colors.White, - modifier = Modifier.size(16.dp) - ) + + AnimatedVisibility(visible = networkFee > 0L, enter = fadeIn(), exit = fadeOut()) { + Row(verticalAlignment = Alignment.CenterVertically) { + MoneySSB(sats = networkFee) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painterResource(R.drawable.ic_pencil_simple), + contentDescription = null, + tint = Colors.White, + modifier = Modifier.size(16.dp) + ) + } } - Spacer(modifier = Modifier.weight(1f)) - HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) } FeeInfo( label = stringResource(R.string.lightning__spending_confirm__lsp_fee), @@ -141,7 +142,7 @@ private fun ExternalConfirmContent( ) { FeeInfo( label = stringResource(R.string.lightning__spending_confirm__amount), - amount = uiState.localBalance, + amount = uiState.amount.sats, ) FeeInfo( label = stringResource(R.string.lightning__spending_confirm__total), @@ -173,13 +174,27 @@ private fun ExternalConfirmContent( } } -@Preview(showSystemUi = true, showBackground = true) +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = ExternalNodeContract.UiState( + amount = ExternalNodeContract.UiState.Amount(sats = 45_500L), + networkFee = 2_100L, + ) + ) + } +} + +@Preview(showSystemUi = true) @Composable -private fun ExternalConfirmScreenPreview() { +private fun PreviewFeeLoading() { AppThemeSurface { - ExternalConfirmContent( + Content( uiState = ExternalNodeContract.UiState( - localBalance = 45_500L, + amount = ExternalNodeContract.UiState.Amount(sats = 45_500L), + networkFee = 0L, ) ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt index 9682edaf6..7a83722e6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt @@ -50,9 +50,7 @@ import to.bitkit.ui.screens.scanner.SCAN_RESULT_KEY import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.ExternalNodeContract -import to.bitkit.viewmodels.ExternalNodeContract.SideEffect -import to.bitkit.viewmodels.ExternalNodeViewModel +import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect @Composable fun ExternalConnectionScreen( diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt index e9e2f662d..2ac299577 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt @@ -1,40 +1,184 @@ package to.bitkit.ui.screens.transfer.external -import androidx.compose.foundation.layout.Box +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.models.BITCOIN_SYMBOL +import to.bitkit.models.Toast +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.LargeRow +import to.bitkit.ui.components.NumberPadSimple +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.CloseNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent @Composable fun ExternalFeeCustomScreen( - onBackClick: () -> Unit = {}, - onCloseClick: () -> Unit = {}, + viewModel: ExternalNodeViewModel, + onBack: () -> Unit, + onClose: () -> Unit, ) { + val uiState by viewModel.uiState.collectAsState() + val currency = currencyViewModel ?: return + val scope = rememberCoroutineScope() + + val context = LocalContext.current + + var input by remember { + mutableStateOf(uiState.customFeeRate?.toString() ?: "") + } + + LaunchedEffect(input) { + val feeRate = input.toUIntOrNull() ?: 0u + viewModel.onCustomFeeRateChange(feeRate) + } + + val totalFeeText = remember(uiState.networkFee) { + if (uiState.networkFee == 0L) { + "" + } else { + currency.convert(uiState.networkFee) + ?.let { + context.getString(R.string.wallet__send_fee_total_fiat) + .replace("{feeSats}", "${uiState.networkFee}") + .replace("{fiatSymbol}", it.symbol) + .replace("{fiatFormatted}", it.formatted) + } ?: context.getString(R.string.wallet__send_fee_total).replace("{feeSats}", "${uiState.networkFee}") + } + } + + Content( + input = input, + totalFeeText = totalFeeText, + onKeyPress = { key -> + when (key) { + KEY_DELETE -> input = if (input.isNotEmpty()) input.dropLast(1) else "" + else -> if (input.length < 3) input = (input + key).trimStart('0') + } + }, + onContinue = { + val feeRate = input.toUIntOrNull() ?: 0u + if (feeRate == 0u) { + scope.launch { + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.wallet__min_possible_fee_rate), + description = context.getString(R.string.wallet__min_possible_fee_rate_msg), + ) + } + return@Content + } + onBack() + }, + onBack = onBack, + onClose = onClose, + ) +} + +@Composable +private fun Content( + input: String, + totalFeeText: String, + onKeyPress: (String) -> Unit = {}, + onContinue: () -> Unit = {}, + onBack: () -> Unit = {}, + onClose: () -> Unit = {}, +) { + val feeRate = input.toUIntOrNull() ?: 0u + val isValid = feeRate != 0u + ScreenColumn { AppTopBar( titleText = stringResource(R.string.lightning__external__nav_title), - onBackClick = onBackClick, - actions = { CloseNavIcon(onCloseClick) }, + onBackClick = onBack, + actions = { CloseNavIcon(onClose) }, ) - Box(modifier = Modifier.fillMaxSize()) { - Text("TODO: ExternalFeeCustomScreen", modifier = Modifier.align(Alignment.Center)) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + ) { + VerticalSpacer(16.dp) + Display(stringResource(R.string.lightning__transfer__custom_fee).withAccent(accentColor = Colors.Purple)) + + FillHeight(1f) + + Column { + Caption13Up(stringResource(R.string.common__sat_vbyte), color = Colors.White64) + + VerticalSpacer(16.dp) + LargeRow( + prefix = null, + text = input.ifEmpty { "0" }, + symbol = BITCOIN_SYMBOL, + showSymbol = true, + ) + VerticalSpacer(8.dp) + + Column( + modifier = Modifier.height(22.dp) + ) { + if (isValid) { + AnimatedVisibility(visible = totalFeeText.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) { + BodyM(totalFeeText, color = Colors.White64) + } + } + } + } + + FillHeight(1f) + + NumberPadSimple( + onPress = onKeyPress, + modifier = Modifier.height(350.dp) + ) + + PrimaryButton( + onClick = onContinue, + text = stringResource(R.string.common__continue) + ) + VerticalSpacer(16.dp) } } } -@Preview(showSystemUi = true, showBackground = true) +@Preview(showSystemUi = true) @Composable -private fun ExternalFeeCustomScreenPreview() { +private fun Preview() { AppThemeSurface { - ExternalFeeCustomScreen() + Content( + input = "5", + totalFeeText = "₿ 256 for average transaction ($0.25)" + ) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt similarity index 57% rename from app/src/main/java/to/bitkit/viewmodels/ExternalNodeViewModel.kt rename to app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index 5b4a1b12c..dec4085b0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -1,4 +1,4 @@ -package to.bitkit.viewmodels +package to.bitkit.ui.screens.transfer.external import android.content.Context import androidx.lifecycle.ViewModel @@ -9,20 +9,26 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.UserChannelId import to.bitkit.R +import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult import to.bitkit.ext.watchUntil import to.bitkit.models.LnPeer import to.bitkit.models.Toast +import to.bitkit.models.TransactionSpeed +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService +import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect +import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.UiState import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.viewmodels.ExternalNodeContract.SideEffect -import to.bitkit.viewmodels.ExternalNodeContract.UiState import javax.inject.Inject @HiltViewModel @@ -30,6 +36,9 @@ class ExternalNodeViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningService: LightningService, private val ldkNodeEventBus: LdkNodeEventBus, + private val walletRepo: WalletRepo, + private val lightningRepo: LightningRepo, + private val settingsStore: SettingsStore, ) : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() @@ -38,6 +47,19 @@ class ExternalNodeViewModel @Inject constructor( val effects = _effects.asSharedFlow() private fun setEffect(effect: SideEffect) = viewModelScope.launch { _effects.emit(effect) } + init { + observeState() + } + + private fun observeState() { + viewModelScope.launch { + walletRepo.balanceState.collect { + val maxAmount = walletRepo.getMaxSendAmount() + _uiState.update { it.copy(amount = it.amount.copy(max = maxAmount.toLong())) } + } + } + } + fun onConnectionContinue(peer: LnPeer) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } @@ -61,7 +83,7 @@ class ExternalNodeViewModel @Inject constructor( fun parseNodeUri(uriString: String) { viewModelScope.launch { - val result = LnPeer.parseUri(uriString) + val result = LnPeer.parseUri(uriString.trim()) if (result.isSuccess) { _uiState.update { it.copy(peer = result.getOrNull()) } @@ -74,17 +96,79 @@ class ExternalNodeViewModel @Inject constructor( } } - fun onAmountContinue(satsAmount: Long) { - _uiState.update { it.copy(localBalance = satsAmount) } + fun onAmountChange(sats: Long) { + val maxAmount = _uiState.value.amount.max + + if (sats > maxAmount) { + viewModelScope.launch { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.lightning__spending_amount__error_max__title), + description = context.getString(R.string.lightning__spending_amount__error_max__description) + .replace("{amount}", maxAmount.formatToModernDisplay()), + ) + } + return + } + + _uiState.update { it.copy(amount = it.amount.copy(sats = sats, overrideSats = null)) } + } + + fun onAmountOverride(sats: Long) { + val max = _uiState.value.amount.max + val nextAmount = minOf(sats, max) + + _uiState.update { it.copy(amount = it.amount.copy(overrideSats = nextAmount)) } + } + + fun onAmountContinue() { + viewModelScope.launch { + val speed = settingsStore.data.first().defaultTransactionSpeed + val defaultSatsPerVbyte = lightningRepo.getFeeRateForSpeed(speed).getOrThrow().toUInt() + _uiState.update { + it.copy( + customFeeRate = defaultSatsPerVbyte, + networkFee = 0L, + ) + } + updateNetworkFee() + } + } + + fun onCustomFeeRateChange(feeRate: UInt) { + _uiState.update { it.copy(customFeeRate = feeRate) } + updateNetworkFee() + } + + private fun updateNetworkFee() { + viewModelScope.launch { + val amountSats = _uiState.value.amount.sats + val customFeeRate = _uiState.value.customFeeRate + + if (amountSats <= 0 || customFeeRate == 0u) { + _uiState.update { it.copy(networkFee = 0L) } + return@launch + } + + val speed = customFeeRate?.let { TransactionSpeed.Custom(it) } + + val fee = lightningRepo.calculateTotalFee( + amountSats = amountSats.toULong(), + speed = speed, + ).getOrDefault(1000uL) + + _uiState.update { it.copy(networkFee = fee.toLong()) } + } } fun onConfirm() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - val result = lightningService.openChannel( + // TODO: pass customFeeRate to ldk-node when supported + val result = lightningRepo.openChannel( peer = requireNotNull(_uiState.value.peer), - channelAmountSats = _uiState.value.localBalance.toULong(), + channelAmountSats = _uiState.value.amount.sats.toULong(), ) if (result.isSuccess) { @@ -143,8 +227,16 @@ interface ExternalNodeContract { data class UiState( val isLoading: Boolean = false, val peer: LnPeer? = null, - val localBalance: Long = 0, - ) + val amount: Amount = Amount(), + val networkFee: Long = 0, + val customFeeRate: UInt? = null, + ) { + data class Amount( + val sats: Long = 0, + val max: Long = 0, + val overrideSats: Long? = null, + ) + } sealed interface SideEffect { data object ConnectionSuccess : SideEffect diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/BoostTransactionViewModel.kt index 82e4b19db..3bf06d404 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/BoostTransactionViewModel.kt @@ -27,7 +27,7 @@ import javax.inject.Inject class BoostTransactionViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, - private val activityRepo: ActivityRepo + private val activityRepo: ActivityRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(BoostTransactionUiState()) @@ -83,7 +83,6 @@ class BoostTransactionViewModel @Inject constructor( // TODO ideally include utxos for a better fee estimate val totalFeeResult = lightningRepo.calculateTotalFee( - address = walletRepo.getOnchainAddress(), amountSats = activityContent.value, speed = TransactionSpeed.Custom(feeRateResult.getOrDefault(0u).toUInt()), ) @@ -177,7 +176,7 @@ class BoostTransactionViewModel @Inject constructor( lightningRepo.accelerateByCpfp( satsPerVByte = _uiState.value.feeRate.toUInt(), originalTxId = activity.v1.txId, - destinationAddress = walletRepo.getOnchainAddress() + destinationAddress = walletRepo.getOnchainAddress(), ).fold( onSuccess = { newTxId -> handleBoostSuccess(newTxId, isRBF = false) @@ -228,7 +227,6 @@ class BoostTransactionViewModel @Inject constructor( viewModelScope.launch { lightningRepo .calculateTotalFee( - address = walletRepo.getOnchainAddress(), amountSats = requireNotNull(activity).v1.value, speed = TransactionSpeed.Custom(newFeeRate.toUInt()), ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/CoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/CoinSelectionViewModel.kt index bfa37f49b..bca890465 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/CoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/CoinSelectionViewModel.kt @@ -143,8 +143,8 @@ class CoinSelectionViewModel @Inject constructor( ): ULong { return lightningRepo .calculateTotalFee( - address = address, amountSats = amountSats, + address = address, utxosToSpend = utxosToSpend, ) .map { fee -> amountSats + fee } diff --git a/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt index 89b41dbef..f6062c921 100644 --- a/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,12 +42,13 @@ fun CustomFeeSettingsScreen( ) { val settings = settingsViewModel ?: return val customFeeRate = settings.defaultTransactionSpeed.collectAsStateWithLifecycle() + val currency = currencyViewModel ?: return + var converted: ConvertedAmount? by remember { mutableStateOf(null) } + var input by remember { mutableStateOf((customFeeRate.value as? TransactionSpeed.Custom)?.satsPerVByte?.toString() ?: "") } - val currency = currencyViewModel ?: return val totalFee = Env.TransactionDefaults.recommendedBaseFee * (input.toUIntOrNull() ?: 0u) - var converted: ConvertedAmount? by remember { mutableStateOf(null) } LaunchedEffect(input) { val inputNum = input.toLongOrNull() ?: 0 @@ -116,7 +116,7 @@ private fun CustomFeeSettingsContent( Spacer(modifier = Modifier.height(16.dp)) LargeRow( prefix = null, - text = if (input.isEmpty()) "0" else input, + text = input.ifEmpty { "0" }, symbol = BITCOIN_SYMBOL, showSymbol = true, ) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index ac93a9645..f49585ec5 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -16,6 +16,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking +import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain @@ -45,6 +46,7 @@ class LightningRepoTest : BaseUnitTest() { private val blocktankNotificationsService: BlocktankNotificationsService = mock() private val firebaseMessaging: FirebaseMessaging = mock() private val keychain: Keychain = mock() + private val cacheStore: CacheStore = mock() private val lnUrlWithdrawService: LnUrlWithdrawService = mock() @@ -60,7 +62,8 @@ class LightningRepoTest : BaseUnitTest() { blocktankNotificationsService = blocktankNotificationsService, firebaseMessaging = firebaseMessaging, keychain = keychain, - lnUrlWithdrawService = lnUrlWithdrawService + lnUrlWithdrawService = lnUrlWithdrawService, + cacheStore = cacheStore, ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/activity/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/activity/BoostTransactionViewModelTest.kt index c9427bc44..7b19e0b6d 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/activity/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/activity/BoostTransactionViewModelTest.kt @@ -2,7 +2,6 @@ package to.bitkit.ui.screens.wallets.activity import app.cash.turbine.test import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import kotlinx.coroutines.test.runTest @@ -35,7 +34,7 @@ class BoostTransactionViewModelSimplifiedTest : BaseUnitTest() { // Test data private val mockTxId = "test_txid_123" private val mockNewTxId = "new_txid_456" - private val mockAddress = "bc1qtest123" + private val mockAddress = "bc1rt1test123" private val testFeeRate = 10UL private val testTotalFee = 1000UL private val testValue = 50000UL @@ -89,7 +88,7 @@ class BoostTransactionViewModelSimplifiedTest : BaseUnitTest() { fun `setupActivity should set loading state initially`() = runTest { whenever(lightningRepo.getFeeRateForSpeed(any())) .thenReturn(Result.success(testFeeRate)) - whenever(lightningRepo.calculateTotalFee(any(), any(), anyOrNull(), anyOrNull())) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(testTotalFee)) sut.uiState.test { @@ -106,15 +105,13 @@ class BoostTransactionViewModelSimplifiedTest : BaseUnitTest() { fun `setupActivity should call correct repository methods for sent transaction`() = runTest { whenever(lightningRepo.getFeeRateForSpeed(TransactionSpeed.Fast)) .thenReturn(Result.success(testFeeRate)) - whenever(walletRepo.getOnchainAddress()) - .thenReturn(mockAddress) - whenever(lightningRepo.calculateTotalFee(any(), any(), anyOrNull(), anyOrNull())) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(testTotalFee)) sut.setupActivity(mockActivitySent) verify(lightningRepo).getFeeRateForSpeed(TransactionSpeed.Fast) - verify(lightningRepo).calculateTotalFee(any(), any(), anyOrNull(), anyOrNull()) + verify(lightningRepo).calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull()) } @Test @@ -155,9 +152,7 @@ class BoostTransactionViewModelSimplifiedTest : BaseUnitTest() { fun `onChangeAmount should emit OnMaxFee when at maximum rate`() = runTest { whenever(lightningRepo.getFeeRateForSpeed(any())) .thenReturn(Result.success(100UL)) // MAX_FEE_RATE - whenever(walletRepo.getOnchainAddress()) - .thenReturn(mockAddress) - whenever(lightningRepo.calculateTotalFee(any(), any(), anyOrNull(), anyOrNull())) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(testTotalFee)) sut.setupActivity(mockActivitySent) @@ -172,9 +167,7 @@ class BoostTransactionViewModelSimplifiedTest : BaseUnitTest() { fun `onChangeAmount should emit OnMinFee when at minimum rate`() = runTest { whenever(lightningRepo.getFeeRateForSpeed(any())) .thenReturn(Result.success(1UL)) // MIN_FEE_RATE - whenever(walletRepo.getOnchainAddress()) - .thenReturn(mockAddress) - whenever(lightningRepo.calculateTotalFee(any(), any(), anyOrNull(), anyOrNull())) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(testTotalFee)) sut.setupActivity(mockActivitySent) @@ -204,7 +197,7 @@ class BoostTransactionViewModelSimplifiedTest : BaseUnitTest() { whenever(lightningRepo.calculateCpfpFeeRate(any())) .thenReturn(Result.success(testFeeRate)) - whenever(lightningRepo.calculateTotalFee(any(), any(), anyOrNull(), anyOrNull())) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(testTotalFee)) whenever(walletRepo.getOnchainAddress()) .thenReturn(mockAddress) @@ -225,7 +218,6 @@ class BoostTransactionViewModelSimplifiedTest : BaseUnitTest() { ) ).thenReturn(Result.success(Activity.Onchain(v1 = newActivity))) - // Fix: Mock updateActivity with 3 parameters (likely id, activity, and a third parameter) whenever(activityRepo.updateActivity(any(), any(), any())) .thenReturn(Result.success(Unit))