diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d39bc6233..93916fae7 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.Toast import to.bitkit.models.WidgetType import to.bitkit.ui.components.AuthCheckScreen import to.bitkit.ui.components.BottomSheetType @@ -494,6 +495,14 @@ private fun RootNavHost( onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, onOrderCreated = { navController.navigate(Routes.SpendingConfirm) }, + toastException = { appViewModel.toast(it) }, + toast = { title, description -> + appViewModel.toast( + type = Toast.ToastType.ERROR, + title = title, + description = description + ) + }, ) } composableWithDefaultTransitions { 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 d3492e35a..04b58c306 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 @@ -12,42 +12,34 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.platform.LocalContext 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 kotlinx.coroutines.launch import to.bitkit.R -import to.bitkit.models.Toast -import to.bitkit.ui.LocalBalances import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.appViewModel -import to.bitkit.ui.blocktankViewModel import to.bitkit.ui.components.AmountInput import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth import to.bitkit.ui.components.MoneySSB import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.UnitButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.CloseNavIcon 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.utils.Logger +import to.bitkit.viewmodels.CurrencyUiState +import to.bitkit.viewmodels.TransferEffect +import to.bitkit.viewmodels.TransferToSpendingUiState import to.bitkit.viewmodels.TransferViewModel -import kotlin.math.max -import kotlin.math.min -import kotlin.math.roundToLong @Composable fun SpendingAmountScreen( @@ -55,14 +47,49 @@ fun SpendingAmountScreen( onBackClick: () -> Unit = {}, onCloseClick: () -> Unit = {}, onOrderCreated: () -> Unit = {}, + toastException: (Throwable) -> Unit, + toast: (title: String, description: String) -> Unit, ) { - val scope = rememberCoroutineScope() - val app = appViewModel ?: return - val blocktank = blocktankViewModel ?: return val currencies = LocalCurrencies.current - val resources = LocalContext.current.resources - val transferValues by viewModel.transferValues.collectAsStateWithLifecycle() + val uiState by viewModel.spendingUiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + viewModel.updateLimits(retry = true) + } + + LaunchedEffect(Unit) { + viewModel.transferEffects.collect { effect -> + when (effect) { + TransferEffect.OnOrderCreated -> onOrderCreated() + is TransferEffect.ToastError -> toast(effect.title, effect.description) + is TransferEffect.ToastException -> toastException(effect.e) + } + } + } + + Content( + uiState = uiState, + currencies = currencies, + onBackClick = onBackClick, + onCloseClick = onCloseClick, + onClickQuarter = viewModel::onClickQuarter, + onClickMaxAmount = viewModel::onClickMaxAmount, + onConfirmAmount = viewModel::onConfirmAmount, + onAmountChanged = viewModel::onAmountChanged + ) +} + +@Composable +private fun Content( + uiState: TransferToSpendingUiState, + currencies: CurrencyUiState, + onBackClick: () -> Unit, + onCloseClick: () -> Unit, + onClickQuarter: () -> Unit, + onClickMaxAmount: () -> Unit, + onConfirmAmount: () -> Unit, + onAmountChanged: (Long) -> Unit, +) { ScreenColumn { AppTopBar( titleText = stringResource(R.string.lightning__transfer__nav_title), @@ -75,56 +102,20 @@ fun SpendingAmountScreen( .fillMaxSize() .imePadding() ) { - var satsAmount by rememberSaveable { mutableLongStateOf(0) } - var overrideSats: Long? by remember { mutableStateOf(null) } - var isLoading by remember { mutableStateOf(false) } - - val availableAmount = LocalBalances.current.totalOnchainSats - 512u // default tx fee - var maxLspFee by remember { mutableStateOf(0uL) } - - val feeMaximum = max(0, availableAmount.toLong() - maxLspFee.toLong()) - val maximum = min( - transferValues.maxClientBalance.toLong(), - feeMaximum - ) // TODO USE MAX AVAILABLE TO TRANSFER INSTEAD OF MAX ONCHAIN BALANCE - - // Update maxClientBalance Effect - LaunchedEffect(satsAmount) { - viewModel.updateTransferValues(satsAmount.toULong()) - Logger.debug( - "satsAmount changed - maxClientBalance: ${transferValues.maxClientBalance}", - context = "SpendingAmountScreen" - ) - } - - // Update maxLspFee Effect - LaunchedEffect(availableAmount, transferValues.maxLspBalance) { // TODO MOVE TO VIEWMODEL - runCatching { - val estimate = blocktank.estimateOrderFee( - spendingBalanceSats = availableAmount, - receivingBalanceSats = transferValues.maxLspBalance, - ) - maxLspFee = estimate.feeSat - } - } - - Spacer(modifier = Modifier.height(32.dp)) + VerticalSpacer(32.dp) Display( text = stringResource(R.string.lightning__spending_amount__title) .withAccent(accentColor = Colors.Purple) ) - Spacer(modifier = Modifier.height(32.dp)) + VerticalSpacer(32.dp) AmountInput( primaryDisplay = currencies.primaryDisplay, - overrideSats = overrideSats, - onSatsChange = { sats -> - satsAmount = sats - overrideSats = null - }, + overrideSats = uiState.overrideSats, + onSatsChange = onAmountChanged, ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() // Actions Row( @@ -138,64 +129,51 @@ fun SpendingAmountScreen( color = Colors.White64, ) Spacer(modifier = Modifier.height(8.dp)) - MoneySSB(sats = availableAmount.toLong()) + MoneySSB(sats = uiState.balanceAfterFee) } - Spacer(modifier = Modifier.weight(1f)) + FillWidth() UnitButton(color = Colors.Purple) // 25% Button NumberPadActionButton( text = stringResource(R.string.lightning__spending_amount__quarter), color = Colors.Purple, - onClick = { - val quarter = (availableAmount.toDouble() / 4.0).roundToLong() - val amount = min(quarter, maximum) - overrideSats = amount - }, + onClick = onClickQuarter, ) // Max Button NumberPadActionButton( text = stringResource(R.string.common__max), color = Colors.Purple, - onClick = { - overrideSats = maximum - }, + onClick = onClickMaxAmount, ) } HorizontalDivider() - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) PrimaryButton( text = stringResource(R.string.common__continue), - onClick = { - if (transferValues.maxLspBalance == 0uL) { - app.toast( - type = Toast.ToastType.ERROR, - title = resources.getString(R.string.lightning__spending_amount__error_max__title), - description = resources.getString( - R.string.lightning__spending_amount__error_max__description_zero - ), - ) - return@PrimaryButton - } - - isLoading = true - scope.launch { - try { - val order = blocktank.createOrder(satsAmount.toULong()) - viewModel.onOrderCreated(order) - onOrderCreated() - } catch (e: Throwable) { - app.toast(e) - } finally { - isLoading = false - } - } - }, - enabled = !isLoading && satsAmount != 0L, - isLoading = isLoading, + onClick = onConfirmAmount, + enabled = uiState.satsAmount != 0L && uiState.satsAmount <= uiState.maxAllowedToSend, + isLoading = uiState.isLoading, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) } } } + +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = TransferToSpendingUiState(), + currencies = CurrencyUiState(), + onBackClick = {}, + onCloseClick = {}, + onClickQuarter = {}, + onClickMaxAmount = {}, + onConfirmAmount = {}, + onAmountChanged = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index d57c3cd68..14fc66778 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -77,7 +77,7 @@ fun SpendingConfirmScreen( onAdvancedClick = onAdvancedClick, onConfirm = onConfirm, onUseDefaultLspBalanceClick = { viewModel.onUseDefaultLspBalanceClick() }, - onTransferToSpendingConfirm = { order -> order }, + onTransferToSpendingConfirm = { order -> viewModel.onTransferToSpendingConfirm(order) }, order = order, isAdvanced = isAdvanced ) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 5fa62abab..bdfc910b6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -1,16 +1,19 @@ package to.bitkit.viewmodels +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -21,12 +24,14 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails +import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -35,6 +40,7 @@ import javax.inject.Inject import kotlin.math.max import kotlin.math.min import kotlin.math.roundToLong +import kotlin.time.Duration.Companion.seconds const val RETRY_INTERVAL_MS = 1 * 60 * 1000L // 1 minutes in ms const val GIVE_UP_MS = 30 * 60 * 1000L // 30 minutes in ms @@ -42,8 +48,10 @@ private const val EUR_CURRENCY = "EUR" @HiltViewModel class TransferViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, + private val walletRepo: WalletRepo, private val currencyRepo: CurrencyRepo, private val settingsStore: SettingsStore, private val cacheStore: CacheStore, @@ -60,10 +68,93 @@ class TransferViewModel @Inject constructor( private val _transferValues = MutableStateFlow(TransferValues()) val transferValues = _transferValues.asStateFlow() + val transferEffects = MutableSharedFlow() + fun setTransferEffect(effect: TransferEffect) = viewModelScope.launch { transferEffects.emit(effect) } + var retryTimes = 0 + // region Spending - fun onOrderCreated(order: IBtOrder) { - _spendingUiState.update { it.copy(order = order, isAdvanced = false, defaultOrder = null) } + fun onClickMaxAmount() { + _spendingUiState.update { + it.copy( + satsAmount = it.maxAllowedToSend, + overrideSats = it.maxAllowedToSend, + ) + } + updateLimits(false) + } + + fun onClickQuarter() { + val quarter = (_spendingUiState.value.balanceAfterFee.toDouble() * QUARTER).roundToLong() + + if (quarter > _spendingUiState.value.maxAllowedToSend) { + setTransferEffect( + TransferEffect.ToastError( + title = context.getString(R.string.lightning__spending_amount__error_max__title), + description = context.getString( + R.string.lightning__spending_amount__error_max__description + ).replace("{amount}", _spendingUiState.value.maxAllowedToSend.toString()), + ) + ) + } + + _spendingUiState.update { + it.copy( + satsAmount = min(quarter, it.maxAllowedToSend), + overrideSats = min(quarter, it.maxAllowedToSend), + ) + } + updateLimits(false) + } + + fun onConfirmAmount() { + if (_transferValues.value.maxLspBalance == 0uL) { + setTransferEffect( + TransferEffect.ToastError( + title = context.getString(R.string.lightning__spending_amount__error_max__title), + description = context.getString( + R.string.lightning__spending_amount__error_max__description_zero + ), + ) + ) + return + } + + _spendingUiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + blocktankRepo.createOrder(_spendingUiState.value.satsAmount.toULong()) + .onSuccess { order -> + onOrderCreated(order) + _spendingUiState.update { it.copy(isLoading = false) } + }.onFailure { e -> + setTransferEffect(TransferEffect.ToastException(e)) + _spendingUiState.update { it.copy(isLoading = false) } + } + } + } + + fun onAmountChanged(sats: Long) { + if (sats > _spendingUiState.value.maxAllowedToSend) { + setTransferEffect( + TransferEffect.ToastError( + title = context.getString(R.string.lightning__spending_amount__error_max__title), + description = context.getString( + R.string.lightning__spending_amount__error_max__description + ).replace("{amount}", _spendingUiState.value.maxAllowedToSend.toString()), + ) + ) + } + + _spendingUiState.update { it.copy(satsAmount = sats, overrideSats = null) } + + retryTimes = 0 + updateLimits(retry = false) + } + + fun updateLimits(retry: Boolean) { + updateTransferValues(_spendingUiState.value.satsAmount.toULong()) + updateAvailableAmount(retry = retry) } fun onAdvancedOrderCreated(order: IBtOrder) { @@ -133,6 +224,56 @@ class TransferViewModel @Inject constructor( } } + private fun onOrderCreated(order: IBtOrder) { + _spendingUiState.update { it.copy(order = order, isAdvanced = false, defaultOrder = null) } + setTransferEffect(TransferEffect.OnOrderCreated) + } + + private fun updateAvailableAmount(retry: Boolean) { + viewModelScope.launch { + _spendingUiState.update { it.copy(isLoading = true) } + + // Get the max available balance discounting onChain fee + val availableAmount = walletRepo.getMaxSendAmount() + + // Calculate the LSP fee to the total balance + blocktankRepo.estimateOrderFee( + spendingBalanceSats = availableAmount, + receivingBalanceSats = _transferValues.value.maxLspBalance + ).onSuccess { estimate -> + retryTimes = 0 + val maxLspFee = estimate.feeSat + + // Calculate the available balance to send after LSP fee + val balanceAfterLspFee = availableAmount - maxLspFee + + _spendingUiState.update { + // Calculate the max available to send considering the current balance and LSP policy + it.copy( + maxAllowedToSend = min( + _transferValues.value.maxClientBalance.toLong(), + balanceAfterLspFee.toLong() + ), + isLoading = false, + balanceAfterFee = availableAmount.toLong() + ) + } + }.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)) + } + } + } + } + private suspend fun updateOrder(order: IBtOrder): Int { var currentStep = 0 if (order.channel != null) { @@ -193,26 +334,22 @@ class TransferViewModel @Inject constructor( val maxChannelSize1 = (maxChannelSizeSat.toDouble() * 0.98).roundToLong().toULong() // The maximum channel size the user can open including existing channels - val maxChannelSize2 = if (maxChannelSize1 > channelsSize) maxChannelSize1 - channelsSize else 0u + val maxChannelSize2 = (maxChannelSize1 - channelsSize).coerceAtLeast(0u) val maxChannelSizeAvailableToIncrease = min(maxChannelSize1, maxChannelSize2) val minLspBalance = getMinLspBalance(clientBalanceSat, minChannelSizeSat) - val maxLspBalance = if (maxChannelSizeAvailableToIncrease > clientBalanceSat) { - maxChannelSizeAvailableToIncrease - clientBalanceSat - } else { - 0u - } + val maxLspBalance = (maxChannelSizeAvailableToIncrease - clientBalanceSat).coerceAtLeast(0u) val defaultLspBalance = getDefaultLspBalance(clientBalanceSat, maxLspBalance) val maxClientBalance = getMaxClientBalance(maxChannelSizeAvailableToIncrease) - if (maxChannelSizeAvailableToIncrease < clientBalanceSat) { // TODO DISPLAY ERROR + if (maxChannelSizeAvailableToIncrease < clientBalanceSat) { Logger.warn( "Amount clientBalanceSat:$clientBalanceSat too large, max possible: $maxChannelSizeAvailableToIncrease", context = TAG ) } - if (defaultLspBalance < minLspBalance || defaultLspBalance > maxLspBalance) { + if (defaultLspBalance !in minLspBalance..maxLspBalance) { Logger.warn( "Invalid defaultLspBalance:$defaultLspBalance " + "min possible:$maxLspBalance, " + @@ -367,6 +504,8 @@ class TransferViewModel @Inject constructor( companion object { private const val TAG = "TransferViewModel" + private const val RETRY_LIMIT = 5 + private const val QUARTER = 0.25 } } @@ -375,6 +514,11 @@ data class TransferToSpendingUiState( val order: IBtOrder? = null, val defaultOrder: IBtOrder? = null, val isAdvanced: Boolean = false, + val satsAmount: Long = 0, + val overrideSats: Long? = null, + val maxAllowedToSend: Long = 0, + val balanceAfterFee: Long = 0, + val isLoading: Boolean = false, ) data class TransferValues( @@ -383,4 +527,10 @@ data class TransferValues( val maxLspBalance: ULong = 0u, val maxClientBalance: ULong = 0u, ) + +sealed interface TransferEffect { + data object OnOrderCreated : TransferEffect + data class ToastException(val e: Throwable) : TransferEffect + data class ToastError(val title: String, val description: String) : TransferEffect +} // endregion