diff --git a/.editorconfig b/.editorconfig index 57b48717b..1e1508cd0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,7 +11,7 @@ trim_trailing_whitespace = true [*.{kt,kts}] ij_kotlin_allow_trailing_comma = true -ij_kotlin_allow_trailing_comma_on_call_site = true +# ij_kotlin_allow_trailing_comma_on_call_site = true ktlint_code_style = android_studio ktlint_experimental = enabled ktlint_function_naming_ignore_when_annotated_with = Composable diff --git a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt index 075935b05..6ac6bff83 100644 --- a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt +++ b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt @@ -15,6 +15,22 @@ val ChannelDetails.amountOnClose: ULong return outboundCapacitySat + reserveSats } +/** Returns only `open` channels, filtering out pending ones. */ +fun List.filterOpen(): List { + return this.filter { it.isChannelReady } +} + +/** Returns only `pending` channels. */ +fun List.filterPending(): List { + return this.filterNot { it.isChannelReady } +} + +/** Returns a limit in sats as close as possible to the HTLC limit we can currently send. */ +fun List.totalNextOutboundHtlcLimitSats(): ULong { + return this.filter { it.isUsable } + .sumOf { it.nextOutboundHtlcLimitMsat / 1000uL } +} + fun createChannelDetails(): ChannelDetails { return ChannelDetails( channelId = "channelId", diff --git a/app/src/main/java/to/bitkit/ext/Lnurl.kt b/app/src/main/java/to/bitkit/ext/Lnurl.kt new file mode 100644 index 000000000..eb9d33cf2 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/Lnurl.kt @@ -0,0 +1,10 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.LnurlPayData +import com.synonym.bitkitcore.LnurlWithdrawData + +fun LnurlPayData.commentAllowed(): Boolean = commentAllowed?.let { it > 0u } == true +fun LnurlPayData.maxSendableSat(): ULong = maxSendable / 1000u +fun LnurlPayData.minSendableSat(): ULong = minSendable / 1000u + +fun LnurlWithdrawData.maxWithdrawableSat(): ULong = maxWithdrawable / 1000u diff --git a/app/src/main/java/to/bitkit/models/BalanceState.kt b/app/src/main/java/to/bitkit/models/BalanceState.kt index e50547f0b..37e06143c 100644 --- a/app/src/main/java/to/bitkit/models/BalanceState.kt +++ b/app/src/main/java/to/bitkit/models/BalanceState.kt @@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable data class BalanceState( val totalOnchainSats: ULong = 0uL, val totalLightningSats: ULong = 0uL, + val maxSendLightningSats: ULong = 0uL, // TODO use where applicable val totalSats: ULong = 0uL, ) diff --git a/app/src/main/java/to/bitkit/models/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index b04a66bef..1741d2faa 100644 --- a/app/src/main/java/to/bitkit/models/Currency.kt +++ b/app/src/main/java/to/bitkit/models/Currency.kt @@ -3,12 +3,14 @@ package to.bitkit.models import kotlinx.datetime.Instant import kotlinx.serialization.Serializable import java.math.BigDecimal +import java.math.RoundingMode import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Locale const val BITCOIN_SYMBOL = "₿" const val SATS_IN_BTC = 100_000_000 +const val BTC_SCALE = 8 const val BTC_PLACEHOLDER = "0.00000000" const val SATS_PLACEHOLDER = "0" @@ -54,7 +56,7 @@ data class ConvertedAmount( val flag: String, val sats: Long, ) { - val btcValue: BigDecimal = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC)) + val btcValue: BigDecimal = sats.asBtc() data class BitcoinDisplayComponents( val symbol: String, @@ -96,3 +98,6 @@ fun Long.formatToModernDisplay(): String { } fun ULong.formatToModernDisplay(): String = this.toLong().formatToModernDisplay() + +/** Represent this sat value in Bitcoin BigDecimal. */ +fun Long.asBtc(): BigDecimal = BigDecimal(this).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 85aa8af91..bfd971892 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -23,12 +23,14 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.models.BTC_SCALE import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount import to.bitkit.models.FxRate import to.bitkit.models.PrimaryDisplay import to.bitkit.models.SATS_IN_BTC import to.bitkit.models.Toast +import to.bitkit.models.asBtc import to.bitkit.services.CurrencyService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.utils.formatCurrency @@ -196,16 +198,16 @@ class CurrencyRepo @Inject constructor( ) ) - val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP) - val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) - val formatted = value.formatCurrency() ?: return Result.failure( + val btcAmount = sats.asBtc() + val fiatValue = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) + val formatted = fiatValue.formatCurrency() ?: return Result.failure( IllegalStateException( - "Failed to format value: $value for currency: $targetCurrency" + "Failed to format value: $fiatValue for currency: $targetCurrency" ) ) ConvertedAmount( - value = value, + value = fiatValue, formatted = formatted, symbol = rate.currencySymbol, currency = rate.quote, @@ -243,7 +245,6 @@ class CurrencyRepo @Inject constructor( companion object { private const val TAG = "CurrencyRepo" - private const val BTC_SCALE = 8 } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 081fb880b..9dc270e04 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1,8 +1,10 @@ package to.bitkit.repositories -import android.net.Uri import com.google.firebase.messaging.FirebaseMessaging +import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.createWithdrawCallbackUrl +import com.synonym.bitkitcore.decode import com.synonym.bitkitcore.getLnurlInvoice import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay @@ -39,7 +41,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService import to.bitkit.services.LnUrlWithdrawResponse -import to.bitkit.services.LnUrlWithdrawService +import to.bitkit.services.LnurlService import to.bitkit.services.NodeEventHandler import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -59,7 +61,7 @@ class LightningRepo @Inject constructor( private val blocktankNotificationsService: BlocktankNotificationsService, private val firebaseMessaging: FirebaseMessaging, private val keychain: Keychain, - private val lnUrlWithdrawService: LnUrlWithdrawService, + private val lnurlService: LnurlService, private val cacheStore: CacheStore, ) { private val _lightningState = MutableStateFlow(LightningState()) @@ -401,12 +403,19 @@ class LightningRepo @Inject constructor( Result.success(invoice) } - suspend fun createLnurlInvoice( - address: String, - amountSatoshis: ULong, - ): Result = executeWhenNodeRunning("getLnUrlInvoice") { - val invoice = getLnurlInvoice(address, amountSatoshis) - Result.success(invoice) + suspend fun fetchLnurlInvoice( + callbackUrl: String, + amountSats: ULong, + comment: String? = null, + ): Result { + return runCatching { + // TODO use bitkit-core getLnurlInvoice if it works with callbackUrl + val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).pr + val decoded = (decode(bolt11) as Scanner.Lightning).invoice + return@runCatching decoded + }.onFailure { + Logger.error("Error fetching lnurl invoice, url: $callbackUrl, amount: $amountSats, comment: $comment", it) + } } suspend fun handleLnUrlWithdraw( @@ -416,40 +425,7 @@ class LightningRepo @Inject constructor( ): Result = executeWhenNodeRunning("create LnUrl withdraw callback") { val callbackUrl = createWithdrawCallbackUrl(k1 = k1, callback = callback, paymentRequest = paymentRequest) Logger.debug("handleLnUrlWithdraw callbackUrl generated:$callbackUrl") - val formattedCallbackUrl = callbackUrl.removeDuplicateQueryParams() - Logger.debug("handleLnUrlWithdraw formatted callbackUrl:$formattedCallbackUrl") - lnUrlWithdrawService.fetchWithdrawInfo(formattedCallbackUrl) - } - - /** - * Extension function to remove duplicate query parameters from a URL string - * Keeps the first occurrence of each parameter - */ - private fun String.removeDuplicateQueryParams(): String { // TODO REMOVE AFTER CORE FIX - return try { - val uri = Uri.parse(this) - val builder = uri.buildUpon().clearQuery() - - // Track seen parameters to avoid duplicates - val seenParams = mutableSetOf() - - // Get all query parameter names - uri.queryParameterNames.forEach { paramName -> - if (!seenParams.contains(paramName)) { - // Add only the first occurrence of each parameter - val value = uri.getQueryParameter(paramName) - if (value != null) { - builder.appendQueryParameter(paramName, value) - seenParams.add(paramName) - } - } - } - - builder.build().toString() - } catch (e: Exception) { - // Return original string if parsing fails - this - } + lnurlService.fetchWithdrawInfo(callbackUrl) } suspend fun payInvoice(bolt11: String, sats: ULong? = null): Result = diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 307f9d66d..831135836 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -25,6 +25,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.toHex +import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState import to.bitkit.models.NodeLifecycleState @@ -162,6 +163,7 @@ class WalletRepo @Inject constructor( val newBalance = BalanceState( totalOnchainSats = balance.totalOnchainBalanceSats, totalLightningSats = balance.totalLightningBalanceSats, + maxSendLightningSats = lightningRepo.getChannels()?.totalNextOutboundHtlcLimitSats() ?: 0u, totalSats = totalSats, ) _balanceState.update { newBalance } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index d7e887dd3..76f273e10 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -341,7 +341,7 @@ class ActivityService( value = payment.amountSats ?: 0u, fee = (payment.feePaidMsat ?: 0u) / 1000u, invoice = "lnbc123_todo", // TODO - message = "", + message = kind.description.orEmpty(), timestamp = payment.latestUpdateTimestamp, preimage = kind.preimage, createdAt = payment.latestUpdateTimestamp, diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 5a35cb2e4..eba840ede 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -41,6 +41,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.DatePattern +import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.ext.uByteList import to.bitkit.models.ElectrumServer import to.bitkit.models.LnPeer @@ -390,9 +391,7 @@ class LightningService @Inject constructor( return false } - val totalNextOutboundHtlcLimitSats = channels - .filter { it.isUsable } - .sumOf { it.nextOutboundHtlcLimitMsat / 1000uL } + val totalNextOutboundHtlcLimitSats = channels.totalNextOutboundHtlcLimitSats() if (totalNextOutboundHtlcLimitSats < amountSats) { Logger.warn("Insufficient outbound capacity: $totalNextOutboundHtlcLimitSats < $amountSats") @@ -669,16 +668,6 @@ class LightningService @Inject constructor( // region helpers -/** Returns only `open` channels, filtering out pending ones. */ -fun List.filterOpen(): List { - return this.filter { it.isChannelReady } -} - -/** Returns only `pending` channels. */ -fun List.filterPending(): List { - return this.filterNot { it.isChannelReady } -} - private fun generateLogFilePath(): String { val dateFormatter = SimpleDateFormat(DatePattern.LOG_FILE, Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") diff --git a/app/src/main/java/to/bitkit/services/LnUrlWithdrawService.kt b/app/src/main/java/to/bitkit/services/LnurlService.kt similarity index 55% rename from app/src/main/java/to/bitkit/services/LnUrlWithdrawService.kt rename to app/src/main/java/to/bitkit/services/LnurlService.kt index 86dea8434..42252be62 100644 --- a/app/src/main/java/to/bitkit/services/LnUrlWithdrawService.kt +++ b/app/src/main/java/to/bitkit/services/LnurlService.kt @@ -11,12 +11,13 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class LnUrlWithdrawService @Inject constructor( +class LnurlService @Inject constructor( private val client: HttpClient, ) { - suspend fun fetchWithdrawInfo(lnUrlCallBack: String): Result = runCatching { - val response: HttpResponse = client.get(lnUrlCallBack) + suspend fun fetchWithdrawInfo(callbackUrl: String): Result = runCatching { + val response: HttpResponse = client.get(callbackUrl) + Logger.debug("Http call: $response") if (!response.status.isSuccess()) { throw Exception("HTTP error: ${response.status}") @@ -34,9 +35,28 @@ class LnUrlWithdrawService @Inject constructor( Logger.warn(e = it, msg = "Failed to fetch withdraw info", context = TAG) } + suspend fun fetchLnurlInvoice( + callbackUrl: String, + amountSats: ULong, + comment: String? = null, + ): LnurlPayResponse { + val response = client.get(callbackUrl) { + url { + parameters.append("amount", "${amountSats * 1000u}") // convert to msat + comment?.takeIf { it.isNotBlank() }?.let { parameters.append("comment", it) } + } + } + Logger.debug("Http call: $response") + + if (!response.status.isSuccess()) { + throw Exception("HTTP error: ${response.status}") + } + + return response.body() + } companion object { - private const val TAG = "LnUrlWithdrawService" + private const val TAG = "LnurlService" } } @@ -52,3 +72,9 @@ data class LnUrlWithdrawResponse( val maxWithdrawable: Long? = null, val balanceCheck: String? = null ) + +@Serializable +data class LnurlPayResponse( + val pr: String, + val routes: List, +) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c32e47c97..9d796a660 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -335,8 +335,9 @@ fun ContentView( walletViewModel = walletViewModel, startDestination = sheet.route, onComplete = { txSheet -> - appViewModel.setSendEvent(SendEvent.Reset) + appViewModel.resetSendState() appViewModel.hideSheet() + appViewModel.clearClipboardForAutoRead() txSheet?.let { appViewModel.showNewTransactionSheet(it) } } ) diff --git a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt index ed1b2f719..950aac3fb 100644 --- a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt +++ b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt @@ -23,6 +23,7 @@ import to.bitkit.R import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.ConvertedAmount import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.currencyViewModel import to.bitkit.ui.settingsViewModel @@ -50,7 +51,7 @@ fun BalanceHeaderView( smallRowSymbol = "$", smallRowText = "12.34", largeRowPrefix = prefix, - largeRowText = "$sats", + largeRowText = sats.formatToModernDisplay(), largeRowSymbol = BITCOIN_SYMBOL, showSymbol = showBitcoinSymbol, hideBalance = false, diff --git a/app/src/main/java/to/bitkit/ui/components/Keyboard.kt b/app/src/main/java/to/bitkit/ui/components/Keyboard.kt index 4b620cfed..25af3c5b4 100644 --- a/app/src/main/java/to/bitkit/ui/components/Keyboard.kt +++ b/app/src/main/java/to/bitkit/ui/components/Keyboard.kt @@ -1,18 +1,21 @@ package to.bitkit.ui.components -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -24,21 +27,25 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import to.bitkit.R +import to.bitkit.ui.shared.util.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.InterFontFamily +val buttonHeight = 75.dp // 75 * 4 = 300 height +val buttonHaptic = HapticFeedbackType.LongPress + @Composable fun Keyboard( onClick: (String) -> Unit, onClickBackspace: () -> Unit, modifier: Modifier = Modifier, - isDecimal: Boolean = true + isDecimal: Boolean = true, ) { LazyVerticalGrid( - verticalArrangement = Arrangement.spacedBy(34.dp), columns = GridCells.Fixed(3), - modifier = modifier + userScrollEnabled = false, + modifier = modifier, ) { item { KeyboardButton(text = "1", onClick = onClick) } item { KeyboardButton(text = "2", onClick = onClick) } @@ -52,15 +59,15 @@ fun Keyboard( item { KeyboardButton(text = if (isDecimal) "." else "000", onClick = onClick) } item { KeyboardButton(text = "0", onClick = onClick) } item { - Icon( - painter = painterResource(R.drawable.ic_backspace), - contentDescription = stringResource(R.string.common__delete), - modifier = Modifier - .sizeIn(minHeight = 30.dp) - .padding(vertical = 4.dp) - .clickable(onClick = onClickBackspace) - .testTag("KeyboardButton_backspace") - ) + ButtonBox( + onClick = onClickBackspace, + modifier = Modifier.testTag("KeyboardButton_backspace"), + ) { + Icon( + painter = painterResource(R.drawable.ic_backspace), + contentDescription = stringResource(R.string.common__delete), + ) + } } } } @@ -68,27 +75,45 @@ fun Keyboard( @Composable private fun KeyboardButton( text: String, - onClick: (String) -> Unit + onClick: (String) -> Unit, + modifier: Modifier = Modifier, ) { - Text( - text = text, - style = TextStyle( - fontFamily = InterFontFamily, - fontWeight = FontWeight.Normal, - fontSize = 24.sp, - lineHeight = 44.sp, - letterSpacing = (-0.1).sp, - textAlign = TextAlign.Center, - color = Colors.White, - ), - modifier = Modifier - .clickable( - onClick = { - onClick(text) - }, - onClickLabel = text - ) - .testTag("KeyboardButton_$text"), + ButtonBox( + onClick = { onClick(text) }, + modifier = modifier.testTag("KeyboardButton_$text"), + ) { + Text( + text = text, + style = TextStyle( + fontFamily = InterFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 44.sp, + letterSpacing = (-0.1).sp, + textAlign = TextAlign.Center, + color = Colors.White, + ), + ) + } +} + +@Composable +private fun ButtonBox( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (BoxScope.() -> Unit), +) { + val haptic = LocalHapticFeedback.current + Box( + content = content, + contentAlignment = Alignment.Center, + modifier = modifier + .heightIn(buttonHeight) + .fillMaxSize() + .clickableAlpha(0.2f) { + haptic.performHapticFeedback(buttonHaptic) + onClick() + }, ) } @@ -98,9 +123,10 @@ private fun Preview() { AppThemeSurface { Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { Keyboard( - modifier = Modifier - .fillMaxWidth() - .padding(41.dp), onClick = {}, onClickBackspace = {}) + onClick = {}, + onClickBackspace = {}, + modifier = Modifier.fillMaxWidth(), + ) } } } @@ -112,11 +138,10 @@ private fun Preview2() { Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { Keyboard( isDecimal = false, - modifier = Modifier - .fillMaxWidth() - .padding(41.dp), onClick = {}, - onClickBackspace = {}) + onClickBackspace = {}, + modifier = Modifier.fillMaxWidth(), + ) } } } @@ -128,11 +153,10 @@ private fun Preview3() { Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { Keyboard( isDecimal = false, - modifier = Modifier - .fillMaxWidth() - .padding(41.dp), onClick = {}, - onClickBackspace = {}) + onClickBackspace = {}, + modifier = Modifier.fillMaxWidth(), + ) } } } 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 5b0401a14..9fde35af7 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt @@ -1,17 +1,25 @@ package to.bitkit.ui.components +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +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.theme.AppShapes import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -21,19 +29,35 @@ fun NumberPadActionButton( onClick: () -> Unit, modifier: Modifier = Modifier, color: Color = Colors.Brand, + enabled: Boolean = true, + @DrawableRes icon: Int? = null, ) { + val borderColor = if (enabled) Color.Transparent else color + val bgColor = if (enabled) Colors.White10 else Color.Transparent + Surface( - color = Colors.White10, - shape = RoundedCornerShape(8.dp), + color = bgColor, + shape = AppShapes.small, + border = BorderStroke(1.dp, borderColor), modifier = modifier .clickable( - onClick = { onClick() } + enabled = enabled, + onClick = onClick, ) ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp, horizontal = 8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(PaddingValues(8.dp, 5.dp)) ) { + if (icon != null) { + Icon( + painter = painterResource(icon), + contentDescription = text, + tint = color, + modifier = Modifier.size(16.dp) + ) + } Caption13Up( text = text, color = color, @@ -44,11 +68,25 @@ fun NumberPadActionButton( @Preview @Composable -private fun NumberPadActionButtonPreview() { +private fun Preview() { AppThemeSurface { - Box(modifier = Modifier.padding(16.dp)) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp) + ) { + NumberPadActionButton( + text = "Normal", + onClick = {}, + ) + NumberPadActionButton( + text = "Disabled", + enabled = false, + onClick = {}, + ) NumberPadActionButton( - text = "Action", + text = "Icon", + color = Colors.Purple, + icon = R.drawable.ic_transfer, onClick = {}, ) } diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt index 2c0c4d84c..2d3d8b87d 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt @@ -26,6 +26,7 @@ import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.PrimaryDisplay import to.bitkit.models.SATS_IN_BTC import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.asBtc import to.bitkit.ui.currencyViewModel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -136,7 +137,6 @@ fun NumberPadTextField( ) } - @Composable fun AmountInputHandler( input: String, @@ -144,9 +144,29 @@ fun AmountInputHandler( displayUnit: BitcoinDisplayUnit, onInputChanged: (String) -> Unit, onAmountCalculated: (String) -> Unit, - currencyVM: CurrencyViewModel + currencyVM: CurrencyViewModel, + overrideSats: Long? = null, ) { var lastDisplay by rememberSaveable { mutableStateOf(primaryDisplay) } + + LaunchedEffect(overrideSats) { + overrideSats?.let { sats -> + val newInput = when (primaryDisplay) { + PrimaryDisplay.BITCOIN -> { + if (displayUnit == BitcoinDisplayUnit.MODERN) { + sats.toString() + } else { + sats.asBtc().toString() + } + } + PrimaryDisplay.FIAT -> { + currencyVM.convert(sats)?.formatted ?: "0" + } + } + onInputChanged(newInput) + } + } + LaunchedEffect(primaryDisplay) { if (primaryDisplay == lastDisplay) return@LaunchedEffect lastDisplay = primaryDisplay diff --git a/app/src/main/java/to/bitkit/ui/components/OutlinedColorButton.kt b/app/src/main/java/to/bitkit/ui/components/OutlinedColorButton.kt deleted file mode 100644 index 166ee3d30..000000000 --- a/app/src/main/java/to/bitkit/ui/components/OutlinedColorButton.kt +++ /dev/null @@ -1,74 +0,0 @@ -package to.bitkit.ui.components - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import to.bitkit.ui.theme.AppShapes -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors - -@Composable -fun OutlinedColorButton( - onClick: () -> Unit, - color: Color, - enabled: Boolean = true, - modifier: Modifier = Modifier, - content: @Composable RowScope.() -> Unit, -) { - OutlinedButton( - onClick = onClick, - modifier = modifier.height(28.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = color, - disabledContentColor = color, - ), - enabled = enabled, - shape = AppShapes.small, - contentPadding = PaddingValues(8.dp, 4.dp), - border = BorderStroke(1.dp, color), - ) { - content() - } -} - -@Preview -@Composable -private fun OutlinedColorButtonPreview() { - AppThemeSurface { - Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedColorButton( - onClick = { }, - color = Color.Blue, - ) { - Text("Blue Button") - } - - OutlinedColorButton( - onClick = { }, - color = Color.Red, - ) { - Text("Red Button") - } - - OutlinedColorButton( - onClick = { }, - color = Colors.Purple, - enabled = false, - ) { - Text("Disabled Purple") - } - } - } -} 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 a4a65d26d..54b9b17a5 100644 --- a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt +++ b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt @@ -1,4 +1,4 @@ - package to.bitkit.ui.components +package to.bitkit.ui.components import androidx.annotation.DrawableRes import androidx.compose.animation.core.Animatable @@ -6,19 +6,20 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Check import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -94,7 +95,7 @@ fun SwipeToConfirm( Box( modifier = modifier - .height(CircleSize + Padding * 2) + .requiredHeight(CircleSize + Padding * 2) .clip(CircleShape) .background(Color.White.copy(alpha = 0.16f)) .padding(Padding) @@ -216,11 +217,15 @@ private fun SwipeToConfirmPreview() { val scope = rememberCoroutineScope() AppThemeSurface { Column( + verticalArrangement = Arrangement.Bottom, modifier = Modifier .fillMaxSize() - .padding(16.dp) - .padding(top = 200.dp) + .padding(horizontal = 16.dp) + .systemBarsPadding() ) { + Box( + modifier = Modifier.fillMaxSize() + ) SwipeToConfirm( text = stringResource(R.string.wallet__send_swipe), color = Colors.Green, diff --git a/app/src/main/java/to/bitkit/ui/components/TextInput.kt b/app/src/main/java/to/bitkit/ui/components/TextInput.kt index c4ff4e96c..6c9f26b56 100644 --- a/app/src/main/java/to/bitkit/ui/components/TextInput.kt +++ b/app/src/main/java/to/bitkit/ui/components/TextInput.kt @@ -26,6 +26,7 @@ fun TextInput( singleLine: Boolean = false, isError: Boolean = false, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, leadingIcon: @Composable (() -> Unit)? = null, @@ -49,6 +50,7 @@ fun TextInput( value = value, onValueChange = onValueChange, maxLines = maxLines, + minLines = minLines, singleLine = singleLine, colors = AppTextFieldDefaults.semiTransparent, shape = AppShapes.small, @@ -92,6 +94,15 @@ private fun Preview() { isError = true, modifier = Modifier.fillMaxWidth(), ) + + VerticalSpacer(12.dp) + TextInput( + value = "First line of text \nSecond line of text", + onValueChange = {}, + minLines = 3, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + ) } } } diff --git a/app/src/main/java/to/bitkit/ui/components/UnitButton.kt b/app/src/main/java/to/bitkit/ui/components/UnitButton.kt index 155fe4854..f157a7678 100644 --- a/app/src/main/java/to/bitkit/ui/components/UnitButton.kt +++ b/app/src/main/java/to/bitkit/ui/components/UnitButton.kt @@ -1,23 +1,14 @@ package to.bitkit.ui.components -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.SwapVert -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import to.bitkit.R import to.bitkit.models.PrimaryDisplay import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.currencyViewModel @@ -26,49 +17,37 @@ import to.bitkit.ui.theme.Colors @Composable fun UnitButton( - onClick: () -> Unit = {}, modifier: Modifier = Modifier, + onClick: () -> Unit = {}, color: Color = Colors.Brand, + primaryDisplay: PrimaryDisplay = LocalCurrencies.current.primaryDisplay, ) { val currency = currencyViewModel val currencies = LocalCurrencies.current + val text = if (primaryDisplay == PrimaryDisplay.BITCOIN) "Bitcoin" else currencies.selectedCurrency - Surface( - color = Colors.White10, - shape = RoundedCornerShape(8.dp), - modifier = modifier - .clickable( - onClick = { - currency?.togglePrimaryDisplay() - onClick() - } - ) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp, horizontal = 8.dp) - ) { - Icon( - imageVector = Icons.Default.SwapVert, - contentDescription = null, - tint = color, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Caption13Up( - text = if (currencies.primaryDisplay == PrimaryDisplay.BITCOIN) "BTC" else currencies.selectedCurrency, - color = color, - ) - } - } + NumberPadActionButton( + text = text, + color = color, + onClick = { + currency?.togglePrimaryDisplay() + onClick() + }, + icon = R.drawable.ic_transfer, + modifier = modifier, + ) } @Preview @Composable -private fun UnitButtonPreview() { +private fun Preview() { AppThemeSurface { - Box(modifier = Modifier.padding(16.dp)) { - UnitButton() + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp) + ) { + UnitButton(primaryDisplay = PrimaryDisplay.BITCOIN) + UnitButton(primaryDisplay = PrimaryDisplay.FIAT) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt index 545128ba4..bee3620cd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ext.amountOnClose -import to.bitkit.services.filterOpen +import to.bitkit.ext.filterOpen import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index b793da4b7..ffd0f07ed 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -27,7 +27,7 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.ext.amountOnClose -import to.bitkit.services.filterOpen +import to.bitkit.ext.filterOpen import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index dec4085b0..a343452e2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -29,6 +29,7 @@ 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.utils.Logger import javax.inject.Inject @HiltViewModel @@ -189,6 +190,7 @@ class ExternalNodeViewModel @Inject constructor( } private suspend fun failConfirm(error: String) { + Logger.warn("Error opening channel to '${_uiState.value.peer}': '$error'") _uiState.update { it.copy(isLoading = false) } ToastEventBus.send( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/QuickPaySendScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/QuickPaySendScreen.kt index c90cbe701..1c7a06064 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/QuickPaySendScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/QuickPaySendScreen.kt @@ -8,6 +8,7 @@ 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -18,6 +19,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.models.NodeLifecycleState +import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.Display import to.bitkit.ui.components.SyncNodeView @@ -27,23 +29,30 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.QuickPayData import to.bitkit.viewmodels.QuickPayResult import to.bitkit.viewmodels.QuickPayViewModel @Composable fun QuickPaySendScreen( - invoice: String, - amount: Long, + quickPayData: QuickPayData, onPaymentComplete: () -> Unit, onShowError: (String) -> Unit, viewModel: QuickPayViewModel = hiltViewModel(), ) { + val app = appViewModel ?: return val uiState by viewModel.uiState.collectAsStateWithLifecycle() val lightningState by viewModel.lightningState.collectAsStateWithLifecycle() - LaunchedEffect(invoice, lightningState.nodeLifecycleState) { + LaunchedEffect(quickPayData, lightningState.nodeLifecycleState) { if (lightningState.nodeLifecycleState is NodeLifecycleState.Running) { - viewModel.payInvoice(invoice, amount.toULong()) + viewModel.pay(quickPayData) + } + } + + DisposableEffect(Unit) { + onDispose { + app.resetQuickPayData() } } @@ -56,14 +65,14 @@ fun QuickPaySendScreen( } QuickPaySendScreenContent( - amount = amount, + amount = quickPayData.sats, nodeLifecycleState = lightningState.nodeLifecycleState, ) } @Composable private fun QuickPaySendScreenContent( - amount: Long, + amount: ULong, nodeLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped, ) { Column( @@ -82,7 +91,7 @@ private fun QuickPaySendScreenContent( .padding(horizontal = 16.dp) ) { Spacer(modifier = Modifier.height(16.dp)) - BalanceHeaderView(sats = amount, modifier = Modifier.fillMaxWidth()) + BalanceHeaderView(sats = amount.toLong(), modifier = Modifier.fillMaxWidth()) Spacer(modifier = Modifier.weight(1f)) TransferAnimationView( @@ -113,7 +122,7 @@ private fun QuickPaySendScreenContent( private fun Preview() { AppThemeSurface { QuickPaySendScreenContent( - amount = 50000L, + amount = 50_000u, nodeLifecycleState = NodeLifecycleState.Running, ) } @@ -124,7 +133,7 @@ private fun Preview() { private fun Preview2() { AppThemeSurface { QuickPaySendScreenContent( - amount = 50000L, + amount = 50_000u, nodeLifecycleState = NodeLifecycleState.Initializing, ) } 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 0ea30f720..aed1bd2ea 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 @@ -21,19 +21,22 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.LnurlWithdrawData import to.bitkit.R import to.bitkit.models.BalanceState import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PrimaryDisplay +import to.bitkit.ext.maxSendableSat +import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ui.LocalBalances import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.AmountInputHandler import to.bitkit.ui.components.Keyboard import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.NumberPadTextField -import to.bitkit.ui.components.OutlinedColorButton import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.components.Text13Up @@ -60,14 +63,19 @@ fun SendAmountScreen( ) { val currencyVM = currencyViewModel ?: return var input: String by remember { mutableStateOf("") } + var overrideSats: Long? by remember { mutableStateOf(null) } AmountInputHandler( input = input, + overrideSats = overrideSats, primaryDisplay = currencyUiState.primaryDisplay, displayUnit = currencyUiState.displayUnit, onInputChanged = { newInput -> input = newInput }, - onAmountCalculated = { sats -> onEvent(SendEvent.AmountChange(value = sats)) }, - currencyVM = currencyVM + onAmountCalculated = { sats -> + onEvent(SendEvent.AmountChange(value = sats)) + overrideSats = null + }, + currencyVM = currencyVM, ) SendAmountContent( @@ -79,7 +87,8 @@ fun SendAmountScreen( displayUnit = currencyUiState.displayUnit, onInputChanged = { input = it }, onEvent = onEvent, - onBack = onBack + onBack = onBack, + onClickMax = { maxSats -> overrideSats = maxSats }, ) } @@ -96,6 +105,7 @@ fun SendAmountContent( onInputChanged: (String) -> Unit, onEvent: (SendEvent) -> Unit, onBack: () -> Unit, + onClickMax: (Long) -> Unit = {}, ) { Column( modifier = Modifier @@ -104,10 +114,10 @@ fun SendAmountContent( .navigationBarsPadding() .testTag("send_amount_screen") ) { - val titleRes = if (uiState.lnUrlParameters is LnUrlParameters.LnUrlWithdraw) { - R.string.wallet__lnurl_w_title - } else { - R.string.wallet__send_amount + val titleRes = when (uiState.lnUrlParameters) { + is LnUrlParameters.LnUrlWithdraw -> R.string.wallet__lnurl_w_title + is LnUrlParameters.LnUrlPay -> R.string.wallet__lnurl_p_title + else -> R.string.wallet__send_amount } SheetTopBar(stringResource(titleRes)) { @@ -125,7 +135,8 @@ fun SendAmountContent( balances = balances, displayUnit = displayUnit, primaryDisplay = primaryDisplay, - onEvent = onEvent + onEvent = onEvent, + onMaxClick = onClickMax, ) } @@ -151,19 +162,14 @@ private fun SendAmountNodeRunning( currencyUiState: CurrencyUiState, onInputChanged: (String) -> Unit, onEvent: (SendEvent) -> Unit, + onMaxClick: (Long) -> Unit, ) { - val availableAmount = when { - uiState.lnUrlParameters is LnUrlParameters.LnUrlWithdraw -> { - uiState.lnUrlParameters.data.maxWithdrawable.toLong().div(1000) //Convert from millisats to sats - } - - uiState.payMethod == SendMethod.ONCHAIN -> { - balances.totalOnchainSats.toLong() - } + val isLnurlWithdraw = uiState.lnUrlParameters is LnUrlParameters.LnUrlWithdraw - else -> { - balances.totalLightningSats.toLong() - } + val availableAmount = when { + isLnurlWithdraw -> uiState.lnUrlParameters.data.maxWithdrawableSat().toLong() + uiState.payMethod == SendMethod.ONCHAIN -> balances.totalOnchainSats.toLong() + else -> balances.maxSendLightningSats.toLong() } Column( @@ -201,13 +207,29 @@ private fun SendAmountNodeRunning( Row( verticalAlignment = Alignment.CenterVertically, ) { + // TODO add onClick -> override to max amount MoneySSB(sats = availableAmount) Spacer(modifier = Modifier.weight(1f)) - if (uiState.lnUrlParameters !is LnUrlParameters.LnUrlWithdraw) { + val isLnurl = uiState.lnUrlParameters != null + if (!isLnurl) { PaymentMethodButton(uiState = uiState, onEvent = onEvent) } + if (uiState.lnUrlParameters is LnUrlParameters.LnUrlPay) { + val max = minOf( + uiState.lnUrlParameters.data.maxSendableSat().toLong(), + availableAmount, + ) + NumberPadActionButton( + text = stringResource(R.string.common__max), + onClick = { onMaxClick(max) }, + modifier = Modifier + .height(28.dp) + .testTag("max_amount_button") + ) + + } Spacer(modifier = Modifier.width(8.dp)) UnitButton(modifier = Modifier.height(28.dp)) } @@ -245,28 +267,22 @@ private fun PaymentMethodButton( uiState: SendUiState, onEvent: (SendEvent) -> Unit, ) { - OutlinedColorButton( - onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, - enabled = uiState.isUnified, + NumberPadActionButton( + text = when (uiState.payMethod) { + SendMethod.ONCHAIN -> stringResource(R.string.wallet__savings__title) + SendMethod.LIGHTNING -> stringResource(R.string.wallet__spending__title) + }, color = when (uiState.payMethod) { SendMethod.ONCHAIN -> Colors.Brand SendMethod.LIGHTNING -> Colors.Purple }, + icon = if (uiState.isUnified) R.drawable.ic_transfer else null, + onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, + enabled = uiState.isUnified, modifier = Modifier .height(28.dp) .testTag("payment_method_button") - ) { - Text13Up( - text = when (uiState.payMethod) { - SendMethod.ONCHAIN -> stringResource(R.string.wallet__savings__title) - SendMethod.LIGHTNING -> stringResource(R.string.wallet__spending__title) - }, - color = when (uiState.payMethod) { - SendMethod.ONCHAIN -> Colors.Brand - SendMethod.LIGHTNING -> Colors.Purple - } - ) - } + ) } @Preview(showSystemUi = true, name = "Running - Lightning") @@ -280,7 +296,33 @@ private fun PreviewRunningLightning() { isAmountInputValid = true, isUnified = false ), - balances = BalanceState(totalSats = 150UL, totalOnchainSats = 50UL, totalLightningSats = 100UL), + balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, maxSendLightningSats = 100u), + walletUiState = MainUiState( + nodeLifecycleState = NodeLifecycleState.Running + ), + onBack = {}, + onEvent = {}, + input = "100", + displayUnit = BitcoinDisplayUnit.MODERN, + primaryDisplay = PrimaryDisplay.FIAT, + currencyUiState = CurrencyUiState(), + onInputChanged = {}, + ) + } +} + +@Preview(showSystemUi = true, name = "Running - Unified") +@Composable +private fun PreviewRunningUnified() { + AppThemeSurface { + SendAmountContent( + uiState = SendUiState( + payMethod = SendMethod.LIGHTNING, + amountInput = "100", + isAmountInputValid = true, + isUnified = true, + ), + balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, maxSendLightningSats = 100u), walletUiState = MainUiState( nodeLifecycleState = NodeLifecycleState.Running ), @@ -290,7 +332,7 @@ private fun PreviewRunningLightning() { displayUnit = BitcoinDisplayUnit.MODERN, primaryDisplay = PrimaryDisplay.FIAT, currencyUiState = CurrencyUiState(), - onInputChanged = {} + onInputChanged = {}, ) } } @@ -309,14 +351,14 @@ private fun PreviewRunningOnchain() { walletUiState = MainUiState( nodeLifecycleState = NodeLifecycleState.Running ), - balances = BalanceState(totalSats = 150UL, totalOnchainSats = 50UL, totalLightningSats = 100UL), + balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, maxSendLightningSats = 100u), onBack = {}, onEvent = {}, input = "5000", currencyUiState = CurrencyUiState(), displayUnit = BitcoinDisplayUnit.MODERN, primaryDisplay = PrimaryDisplay.BITCOIN, - onInputChanged = {} + onInputChanged = {}, ) } } @@ -333,19 +375,18 @@ private fun PreviewInitializing() { walletUiState = MainUiState( nodeLifecycleState = NodeLifecycleState.Initializing ), - balances = BalanceState(totalSats = 150UL, totalOnchainSats = 50UL, totalLightningSats = 100UL), + balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, maxSendLightningSats = 100u), onBack = {}, onEvent = {}, displayUnit = BitcoinDisplayUnit.MODERN, primaryDisplay = PrimaryDisplay.BITCOIN, input = "100", currencyUiState = CurrencyUiState(), - onInputChanged = {} + onInputChanged = {}, ) } } - @Preview(showSystemUi = true, name = "Withdraw") @Composable private fun PreviewWithdraw() { @@ -360,24 +401,60 @@ private fun PreviewWithdraw() { callback = "", k1 = "", defaultDescription = "Test", - minWithdrawable = 1UL, - maxWithdrawable = 130UL, + minWithdrawable = 1u, + maxWithdrawable = 130u, tag = "" ), address = "" - ) + ), + ), + walletUiState = MainUiState( + nodeLifecycleState = NodeLifecycleState.Running + ), + balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, totalLightningSats = 100u), + onBack = {}, + onEvent = {}, + displayUnit = BitcoinDisplayUnit.MODERN, + primaryDisplay = PrimaryDisplay.BITCOIN, + input = "100", + currencyUiState = CurrencyUiState(), + onInputChanged = {}, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewLnurlPay() { + AppThemeSurface { + SendAmountContent( + uiState = SendUiState( + payMethod = SendMethod.LIGHTNING, + amountInput = "100", + lnUrlParameters = LnUrlParameters.LnUrlPay( + data = LnurlPayData( + uri = "", + callback = "", + metadataStr = "", + commentAllowed = 255u, + minSendable = 1000u, + maxSendable = 1000_000u, + allowsNostr = false, + nostrPubkey = null, + ), + ), ), walletUiState = MainUiState( nodeLifecycleState = NodeLifecycleState.Running ), - balances = BalanceState(totalSats = 150UL, totalOnchainSats = 50UL, totalLightningSats = 100UL), + balances = BalanceState(totalSats = 150u, totalOnchainSats = 50u, totalLightningSats = 100u), onBack = {}, onEvent = {}, displayUnit = BitcoinDisplayUnit.MODERN, primaryDisplay = PrimaryDisplay.BITCOIN, input = "100", currencyUiState = CurrencyUiState(), - onInputChanged = {} + onInputChanged = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAndReviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAndReviewScreen.kt index b90bcac23..8fa82a163 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAndReviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAndReviewScreen.kt @@ -13,8 +13,11 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +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 @@ -29,34 +32,42 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.DatePattern -import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.commentAllowed import to.bitkit.ext.formatted import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SwipeToConfirm import to.bitkit.ui.components.TagButton +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.settingsViewModel +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.rememberBiometricAuthSupported import to.bitkit.viewmodels.AmountWarning +import to.bitkit.viewmodels.LnUrlParameters import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState @@ -137,7 +148,6 @@ fun SendAndReviewScreen( ) } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun SendAndReviewContent( uiState: SendUiState, @@ -152,17 +162,29 @@ private fun SendAndReviewContent( onBiometricsFailure: () -> Unit, ) { Box { - Column(modifier = Modifier.fillMaxSize()) { - SheetTopBar(stringResource(R.string.wallet__send_review)) { - onBack() - } + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + ) { + val isLnurlPay = uiState.lnUrlParameters is LnUrlParameters.LnUrlPay + + SheetTopBar( + titleText = when { + isLnurlPay -> stringResource(R.string.wallet__lnurl_p_title) + else -> stringResource(R.string.wallet__send_review) + }, + onBack = onBack, + ) Spacer(Modifier.height(16.dp)) Column( modifier = Modifier .padding(horizontal = 16.dp) - .fillMaxWidth() + .fillMaxSize() + .verticalScroll(rememberScrollState()) ) { BalanceHeaderView(sats = uiState.amount.toLong(), modifier = Modifier.fillMaxWidth()) @@ -173,47 +195,22 @@ private fun SendAndReviewContent( SendMethod.LIGHTNING -> LightningDescription(uiState = uiState) } - Spacer(modifier = Modifier.height(16.dp)) - Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) - Spacer(modifier = Modifier.height(8.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) { - uiState.selectedTags.map { tagText -> - TagButton( - text = tagText, - displayIconClose = true, - onClick = { onClickTag(tagText) }, - ) - } + if (isLnurlPay && uiState.lnUrlParameters.data.commentAllowed()) { + LnurlCommentSection(uiState, onEvent) + } else { + TagsSection(uiState, onClickTag, onClickAddTag) } - PrimaryButton( - text = stringResource(R.string.wallet__tags_add), - size = ButtonSize.Small, - onClick = onClickAddTag, - icon = { - Icon( - painter = painterResource(R.drawable.ic_tag), - contentDescription = null, - tint = Colors.Brand - ) - }, - fullWidth = false - ) - HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() + VerticalSpacer(16.dp) + SwipeToConfirm( text = stringResource(R.string.wallet__send_swipe), loading = isLoading, confirmed = isLoading, onConfirm = onSwipeToConfirm, ) - Spacer(modifier = Modifier.height(24.dp)) + VerticalSpacer(16.dp) } } @@ -240,6 +237,65 @@ private fun SendAndReviewContent( } } +@Composable +private fun LnurlCommentSection( + uiState: SendUiState, + onEvent: (SendEvent) -> Unit, +) { + Spacer(modifier = Modifier.height(16.dp)) + Caption13Up(stringResource(R.string.wallet__lnurl_pay_confirm__comment), color = Colors.White64) + Spacer(modifier = Modifier.height(8.dp)) + + TextInput( + value = uiState.comment, + placeholder = stringResource(R.string.wallet__lnurl_pay_confirm__comment_placeholder), + onValueChange = { onEvent(SendEvent.CommentChange(it)) }, + minLines = 3, + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) +} + +@Composable +private fun TagsSection( + uiState: SendUiState, + onClickTag: (String) -> Unit, + onClickAddTag: () -> Unit, +) { + Spacer(modifier = Modifier.height(16.dp)) + Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + uiState.selectedTags.map { tagText -> + TagButton( + text = tagText, + displayIconClose = true, + onClick = { onClickTag(tagText) }, + ) + } + } + PrimaryButton( + text = stringResource(R.string.wallet__tags_add), + size = ButtonSize.Small, + onClick = onClickAddTag, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tag), + contentDescription = stringResource(R.string.wallet__tags_add), + tint = Colors.Brand, + ) + }, + fullWidth = false, + ) + HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) +} + @Composable private fun OnChainDescription( uiState: SendUiState, @@ -250,9 +306,8 @@ private fun OnChainDescription( text = stringResource(R.string.wallet__send_to), color = Colors.White64, ) - val destination = uiState.address.ellipsisMiddle(25) Spacer(modifier = Modifier.height(8.dp)) - BodySSB(text = destination) + BodySSB(text = uiState.address, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) Row( @@ -293,7 +348,7 @@ private fun OnChainDescription( modifier = Modifier .fillMaxHeight() .weight(1f) - .clickable { onEvent(SendEvent.SpeedAndFee) } + .clickableAlpha { onEvent(SendEvent.SpeedAndFee) } .padding(top = 16.dp) ) { Caption13Up(text = stringResource(R.string.wallet__send_confirming_in), color = Colors.White64) @@ -322,14 +377,22 @@ private fun OnChainDescription( private fun LightningDescription( uiState: SendUiState, ) { + val isLnurlPay = uiState.lnUrlParameters is LnUrlParameters.LnUrlPay + val expirySeconds = uiState.decodedInvoice?.expirySeconds + val description = uiState.decodedInvoice?.description + Column(modifier = Modifier.fillMaxWidth()) { Caption13Up( text = stringResource(R.string.wallet__send_invoice), color = Colors.White64, ) - val destination = uiState.bolt11?.ellipsisMiddle(25).orEmpty() + val destination = when { + isLnurlPay -> uiState.lnUrlParameters.data.uri + else -> uiState.decodedInvoice?.bolt11.orEmpty() + } + Spacer(modifier = Modifier.height(8.dp)) - BodySSB(text = destination) + BodySSB(text = destination, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) Row( @@ -359,7 +422,7 @@ private fun LightningDescription( Spacer(modifier = Modifier.weight(1f)) HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) } - uiState.decodedInvoice?.expirySeconds?.let { expirySeconds -> + if (!isLnurlPay && expirySeconds != null) { Column( modifier = Modifier .fillMaxHeight() @@ -368,7 +431,7 @@ private fun LightningDescription( ) { Caption13Up( text = stringResource(R.string.wallet__send_invoice_expiration), - color = Colors.White64 + color = Colors.White64, ) Spacer(modifier = Modifier.height(8.dp)) Row( @@ -381,9 +444,9 @@ private fun LightningDescription( tint = Colors.Purple, modifier = Modifier.size(16.dp) ) - val invoiceExpiryTimestamp = expirySeconds.let { - Instant.now().plusSeconds(it.toLong()).formatted(DatePattern.INVOICE_EXPIRY) - } + val invoiceExpiryTimestamp = Instant.now().plusSeconds(expirySeconds.toLong()) + .formatted(DatePattern.INVOICE_EXPIRY) + BodySSB(text = invoiceExpiryTimestamp) } Spacer(modifier = Modifier.weight(1f)) @@ -392,7 +455,7 @@ private fun LightningDescription( } } - uiState.decodedInvoice?.description?.let { description -> + if (!isLnurlPay && description != null) { Column { Caption13Up(text = stringResource(R.string.wallet__note), color = Colors.White64) Spacer(modifier = Modifier.height(8.dp)) @@ -403,23 +466,68 @@ private fun LightningDescription( } } -@Suppress("SpellCheckingInspection") -@Preview(name = "Lightning") +@Preview(name = "Lightning", showSystemUi = true) @Composable private fun Preview() { AppThemeSurface { SendAndReviewContent( uiState = SendUiState( - amount = 1234uL, + amount = 1234u, + address = "", + payMethod = SendMethod.LIGHTNING, + decodedInvoice = LightningInvoice( + bolt11 = "bolt11_invoice_string", + paymentHash = ByteArray(0), + amountSatoshis = 100_000u, + timestampSeconds = 0u, + expirySeconds = 3600u, + isExpired = false, + networkType = NetworkType.REGTEST, + payeeNodeId = null, + description = "Some invoice description", + ), + ), + isLoading = false, + showBiometrics = false, + onBack = {}, + onEvent = {}, + onClickAddTag = {}, + onClickTag = {}, + onSwipeToConfirm = {}, + onBiometricsSuccess = {}, + onBiometricsFailure = {}, + ) + } +} + +@Suppress("SpellCheckingInspection") +@Preview(name = "LnurlPay", showSystemUi = true) +@Composable +private fun PreviewLnurl() { + AppThemeSurface { + SendAndReviewContent( + uiState = SendUiState( + amount = 1234u, address = "bcrt1qkgfgyxyqhvkdqh04sklnzxphmcds6vft6y7h0r", - bolt11 = "lnbcrt1…", payMethod = SendMethod.LIGHTNING, + lnUrlParameters = LnUrlParameters.LnUrlPay( + data = LnurlPayData( + uri = "veryLongLnurlPayUri12345677890123456789012345678901234567890", + callback = "", + metadataStr = "", + commentAllowed = 255u, + minSendable = 1000u, + maxSendable = 1000_000u, + allowsNostr = false, + nostrPubkey = null, + ), + ), decodedInvoice = LightningInvoice( bolt11 = "bcrt123", paymentHash = ByteArray(0), - amountSatoshis = 100000uL, - timestampSeconds = 0uL, - expirySeconds = 3600uL, + amountSatoshis = 100_000u, + timestampSeconds = 0u, + expirySeconds = 3600u, isExpired = false, networkType = NetworkType.REGTEST, payeeNodeId = null, @@ -440,15 +548,14 @@ private fun Preview() { } @Suppress("SpellCheckingInspection") -@Preview(name = "OnChain") +@Preview(name = "OnChain", showSystemUi = true) @Composable private fun PreviewOnChain() { AppThemeSurface { SendAndReviewContent( uiState = SendUiState( - amount = 1234uL, + amount = 1234u, address = "bcrt1qkgfgyxyqhvkdqh04sklnzxphmcds6vft6y7h0r", - bolt11 = "lnbcrt1…", payMethod = SendMethod.ONCHAIN, selectedTags = listOf("car", "house", "uber"), decodedInvoice = null, @@ -467,15 +574,14 @@ private fun PreviewOnChain() { } @Suppress("SpellCheckingInspection") -@Preview +@Preview(showSystemUi = true) @Composable private fun PreviewBio() { AppThemeSurface { SendAndReviewContent( uiState = SendUiState( - amount = 1234uL, + amount = 1234u, address = "bcrt1qkgfgyxyqhvkdqh04sklnzxphmcds6vft6y7h0r", - bolt11 = "lnbcrt1…", payMethod = SendMethod.ONCHAIN, selectedTags = listOf("car", "house", "uber"), decodedInvoice = null, @@ -494,15 +600,14 @@ private fun PreviewBio() { } @Suppress("SpellCheckingInspection") -@Preview +@Preview(showSystemUi = true) @Composable private fun PreviewDialog() { AppThemeSurface { SendAndReviewContent( uiState = SendUiState( - amount = 1234uL, + amount = 1234u, address = "bcrt1qkgfgyxyqhvkdqh04sklnzxphmcds6vft6y7h0r", - bolt11 = "lnbcrt1…", payMethod = SendMethod.ONCHAIN, selectedTags = listOf("car", "house", "uber"), decodedInvoice = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt index 55df11ad4..72f18c308 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt @@ -2,21 +2,16 @@ package to.bitkit.ui.screens.wallets.send import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -36,7 +31,7 @@ fun SendErrorScreen( onRetry: () -> Unit, onClose: () -> Unit, ) { - SendErrorScreenContent( + Content( errorMessage = errorMessage, onRetry = onRetry, onClose = onClose, @@ -44,11 +39,13 @@ fun SendErrorScreen( } @Composable -private fun SendErrorScreenContent( +private fun Content( errorMessage: String, onRetry: () -> Unit = {}, onClose: () -> Unit = {}, ) { + val errorText = errorMessage.ifEmpty { "Unknown error." } + Column( modifier = Modifier .fillMaxSize() @@ -64,7 +61,7 @@ private fun SendErrorScreenContent( ) { Spacer(modifier = Modifier.height(16.dp)) - BodyM(text = errorMessage, color = Colors.White64) + BodyM(text = errorText, color = Colors.White64) Spacer(modifier = Modifier.weight(1f)) Image( @@ -99,8 +96,18 @@ private fun SendErrorScreenContent( @Composable private fun Preview() { AppThemeSurface { - SendErrorScreenContent( + Content( errorMessage = stringResource(R.string.wallet__send_error_create_tx), ) } } + +@Preview +@Composable +private fun PreviewUnknown() { + AppThemeSurface { + Content( + errorMessage = "", + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt index d8d088132..1e235f0b1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -30,14 +29,11 @@ import androidx.navigation.toRoute import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.R -import to.bitkit.ext.setClipboardText import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.RectangleButton import to.bitkit.ui.components.SheetSize -import to.bitkit.ui.navigateToHome import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.scanner.QrScanningScreen import to.bitkit.ui.screens.wallets.withdraw.WithDrawErrorScreen @@ -59,12 +55,11 @@ fun SendOptionsView( startDestination: SendRoute = SendRoute.Options, onComplete: (NewTransactionSheetDetails?) -> Unit, ) { - val context = LocalContext.current - // Reset on new user-initiated send LaunchedEffect(startDestination) { if (startDestination == SendRoute.Options) { - appViewModel.setSendEvent(SendEvent.Reset) + appViewModel.resetSendState() + appViewModel.resetQuickPayData() } } @@ -83,17 +78,10 @@ fun SendOptionsView( is SendEffect.NavigateToScan -> navController.navigate(SendRoute.QrScanner) is SendEffect.NavigateToCoinSelection -> navController.navigate(SendRoute.CoinSelection) is SendEffect.NavigateToReview -> navController.navigate(SendRoute.ReviewAndSend) - is SendEffect.PaymentSuccess -> { - onComplete(it.sheet) - context.setClipboardText(text = "") - } - - is SendEffect.NavigateToQuickPay -> { - navController.navigate(SendRoute.QuickPay(it.invoice, it.amount)) - } - + is SendEffect.PaymentSuccess -> onComplete(it.sheet) + is SendEffect.NavigateToQuickPay -> navController.navigate(SendRoute.QuickPay) is SendEffect.NavigateToWithdrawConfirm -> navController.navigate(SendRoute.WithdrawConfirm) - SendEffect.NavigateToWithdrawError -> navController.navigate(SendRoute.WithdrawError) + is SendEffect.NavigateToWithdrawError -> navController.navigate(SendRoute.WithdrawError) } } } @@ -195,10 +183,9 @@ fun SendOptionsView( ) } composableWithDefaultTransitions { backStackEntry -> - val route = backStackEntry.toRoute() + val quickPayData by appViewModel.quickPayData.collectAsStateWithLifecycle() QuickPaySendScreen( - invoice = route.invoice, - amount = route.amount, + quickPayData = requireNotNull(quickPayData), onPaymentComplete = { onComplete(null) }, @@ -364,7 +351,7 @@ sealed interface SendRoute { data object CoinSelection : SendRoute @Serializable - data class QuickPay(val invoice: String, val amount: Long) : SendRoute + data object QuickPay : SendRoute @Serializable data class Error(val errorMessage: String) : SendRoute diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 880695e45..bbd44be82 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -28,8 +28,8 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.LdkNodeEventBus -import to.bitkit.services.filterOpen -import to.bitkit.services.filterPending +import to.bitkit.ext.filterOpen +import to.bitkit.ext.filterPending import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AddressChecker import to.bitkit.utils.Logger diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt index 215bd3f11..f6a6dcee8 100644 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt +++ b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt @@ -4,6 +4,7 @@ import okhttp3.internal.toLongOrDefault import to.bitkit.ext.removeSpaces import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.SATS_IN_BTC +import to.bitkit.models.asBtc import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.utils.formatCurrency import to.bitkit.viewmodels.CurrencyViewModel @@ -48,16 +49,10 @@ object CalculatorFormatter { } BitcoinDisplayUnit.CLASSIC -> { - val btcAmount = BigDecimal(satsValue) - .divide(BigDecimal(SATS_IN_BTC)) + satsValue.asBtc() .formatCurrency(decimalPlaces = 8) .orEmpty() - btcAmount } } } - - fun getCleanValue(value: String): String { - return value.removeSpaces().replace(",", "") - } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b64750d41..a5129560c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -9,7 +9,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.LightningInvoice -import com.synonym.bitkitcore.LnurlAddressData import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.LnurlWithdrawData import com.synonym.bitkitcore.OnChainInvoice @@ -39,8 +38,12 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.WatchResult +import to.bitkit.ext.maxSendableSat +import to.bitkit.ext.maxWithdrawableSat +import to.bitkit.ext.minSendableSat import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces +import to.bitkit.ext.setClipboardText import to.bitkit.ext.watchUntil import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -69,7 +72,6 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import java.math.BigDecimal import javax.inject.Inject -import kotlin.onFailure private const val SEND_AMOUNT_WARNING_THRESHOLD = 100.0 @@ -103,6 +105,9 @@ class AppViewModel @Inject constructor( private val _sendUiState = MutableStateFlow(SendUiState()) val sendUiState = _sendUiState.asStateFlow() + private val _quickPayData = MutableStateFlow(null) + val quickPayData = _quickPayData.asStateFlow() + private val _sendEffect = MutableSharedFlow(extraBufferCapacity = 1) val sendEffect = _sendEffect.asSharedFlow() private fun setSendEffect(effect: SendEffect) = viewModelScope.launch { _sendEffect.emit(effect) } @@ -280,9 +285,10 @@ class AppViewModel @Inject constructor( is SendEvent.CoinSelectionContinue -> onCoinSelectionContinue(it.utxos) + is SendEvent.CommentChange -> onCommentChange(it.value) + SendEvent.SpeedAndFee -> toast(Exception("Coming soon: Speed and Fee")) SendEvent.SwipeToPay -> onSwipeToPay() - SendEvent.Reset -> resetSendState() SendEvent.ConfirmAmountWarning -> onConfirmAmountWarning() SendEvent.DismissAmountWarning -> onDismissAmountWarning() SendEvent.PayConfirmed -> onConfirmPay() @@ -335,6 +341,14 @@ class AppViewModel @Inject constructor( } } + private fun onCommentChange(value: String) { + val maxLength = (_sendUiState.value.lnUrlParameters as? LnUrlParameters.LnUrlPay)?.data?.commentAllowed ?: 0u + val trimmed = value.take(maxLength.toInt()) + _sendUiState.update { + it.copy(comment = trimmed) + } + } + private fun onPaymentMethodSwitch() { val nextPaymentMethod = when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> SendMethod.LIGHTNING @@ -365,40 +379,7 @@ class AppViewModel @Inject constructor( return } - if ( - (lnUrlParameters is LnUrlParameters.LnUrlPay || lnUrlParameters is LnUrlParameters.LnUrlAddress) - && _sendUiState.value.bolt11.isNullOrEmpty() - ) { - val address = when (lnUrlParameters) { - is LnUrlParameters.LnUrlAddress -> lnUrlParameters.address - is LnUrlParameters.LnUrlPay -> lnUrlParameters.address - else -> "" - } - - lightningService.createLnurlInvoice( - address = address, - amountSatoshis = _sendUiState.value.amount - ).onSuccess { lightningInvoice -> - _sendUiState.update { - it.copy(bolt11 = lightningInvoice) - } - setSendEffect(SendEffect.NavigateToReview) - }.onFailure { e -> - Logger.error( - "Error generating invoice from lnurl parameters: $lnUrlParameters", - e = e, - context = "AppViewModel" - ) - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.other__scan__error__generic), - ) - } - } else { - setSendEffect(SendEffect.NavigateToReview) - } - + setSendEffect(SendEffect.NavigateToReview) } private fun onCoinSelectionContinue(utxos: List) { @@ -419,15 +400,15 @@ class AppViewModel @Inject constructor( val isValidLNAmount = when (lnUrlParams) { null -> lightningService.canSend(amount) - is LnUrlParameters.LnUrlAddress -> lightningService.canSend(amount) is LnUrlParameters.LnUrlPay -> { - lnUrlParams.data.minSendable / 1000u < amount - && amount < lnUrlParams.data.maxSendable / 1000u - && lightningService.canSend(amount) + val minSat = lnUrlParams.data.minSendableSat() + val maxSat = lnUrlParams.data.maxSendableSat() + + amount in minSat..maxSat && lightningService.canSend(amount) } is LnUrlParameters.LnUrlWithdraw -> { - amount < lnUrlParams.data.maxWithdrawable / 1000u + amount < lnUrlParams.data.maxWithdrawableSat() } } @@ -460,7 +441,7 @@ class AppViewModel @Inject constructor( private suspend fun handleScannedData(uri: String) { val scan = runCatching { scannerService.decode(uri) } - .onFailure { Logger.error("Failed to decode input data $uri", it) } + .onFailure { Logger.error("Failed to decode: '$uri'", it) } .getOrNull() this.scan = scan @@ -474,7 +455,6 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( address = invoice.address, - bolt11 = invoice.lightningParam(), amount = invoice.amountSatoshis, isUnified = invoice.hasLightingParam(), decodedInvoice = lnInvoice, @@ -486,14 +466,10 @@ class AppViewModel @Inject constructor( Logger.info("Found amount in unified invoice, checking QuickPay conditions") val quickPayHandled = handleQuickPayIfApplicable( - invoice = lnInvoice.bolt11, amountSats = lnInvoice.amountSatoshis, + invoice = lnInvoice, ) - - if (quickPayHandled) { - resetSendState() - return - } + if (quickPayHandled) return if (isMainScanner) { showSheet(BottomSheetType.Send(SendRoute.ReviewAndSend)) @@ -513,115 +489,54 @@ class AppViewModel @Inject constructor( } is Scanner.Lightning -> { - val invoice = scan.invoice - handleLightningInvoice(invoice, uri) - } - - is Scanner.LnurlAddress -> { - val data = scan.data - - lightningService.createLnurlInvoice( - address = data.uri, - amountSatoshis = 0UL - ).onSuccess { lightningInvoice -> - val scan = runCatching { scannerService.decode(lightningInvoice) }.getOrNull() - if (scan is Scanner.Lightning) { - val invoice = scan.invoice - handleLightningInvoice( - invoice = invoice, - uri = uri, - lnUrlParameters = LnUrlParameters.LnUrlAddress(data = data, address = uri) - ) - } else { - Logger.error("Error scan is not Lightning type. scan: $scan", context = "AppViewModel") - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.other__scan__error__generic), - ) - resetSendState() - } - }.onFailure { e -> - Logger.error("Error decoding LnurlAddress. data: $data", e = e, context = "AppViewModel") - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.other__scan__error__generic), - ) - resetSendState() - } + handleLightningInvoice(scan.invoice) } is Scanner.LnurlPay -> { val data = scan.data Logger.debug("scan result: LnurlPay: $scan", context = "AppViewModel") - val minSendable = data.minSendable / 1000u - val maxSendable = data.maxSendable / 1000u + val minSendable = data.minSendableSat() + val maxSendable = data.maxSendableSat() if (!lightningService.canSend(minSendable)) { toast( type = Toast.ToastType.WARNING, title = context.getString(R.string.other__lnurl_pay_error), - description = context.getString(R.string.other__lnurl_pay_error_no_capacity) + description = context.getString(R.string.other__lnurl_pay_error_no_capacity), ) resetSendState() return } - if (minSendable == maxSendable && minSendable > 0u) { - Logger.debug( - "LnurlPay: minSendable == maxSendable. navigating directly to confirm screen", - context = "AppViewModel" - ) - lightningService.createLnurlInvoice( - address = data.uri, //TODO We should pass the lnurlAddress not the uri when calling bitkit-core's - amountSatoshis = minSendable, - ).onSuccess { lightningInvoice -> - val scan = runCatching { scannerService.decode(lightningInvoice) }.getOrNull() - if (scan is Scanner.Lightning) { - val invoice = scan.invoice - handleLightningInvoice( - invoice = invoice, - uri = uri, - LnUrlParameters.LnUrlPay(data = data, address = uri) - ) - } else { - Logger.error("Error decoding LNURL pay. scan: $scan", context = "AppViewModel") - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.other__scan__error__generic), - ) - resetSendState() - } - }.onFailure { e -> - Logger.error("Error decoding LNURL pay", e = e, context = "AppViewModel") - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.other__scan__error__generic), - ) - resetSendState() - } - } else { - val lnUrlParameters = LnUrlParameters.LnUrlPay( - data = data, - address = uri + _sendUiState.update { + it.copy( + amount = minSendable, + payMethod = SendMethod.LIGHTNING, + lnUrlParameters = LnUrlParameters.LnUrlPay(data), ) + } - _sendUiState.update { - it.copy( - payMethod = SendMethod.LIGHTNING, - lnUrlParameters = lnUrlParameters - ) - } + val hasAmount = minSendable == maxSendable && minSendable > 0u + if (hasAmount) { + Logger.info("Found amount $$minSendable in lnurlPay, proceeding with payment") + + val quickPayHandled = handleQuickPayIfApplicable(amountSats = minSendable, lnurlPay = data) + if (quickPayHandled) return if (isMainScanner) { - showSheet(BottomSheetType.Send(SendRoute.Amount)) + showSheet(BottomSheetType.Send(SendRoute.ReviewAndSend)) } else { - setSendEffect(SendEffect.NavigateToAmount) + setSendEffect(SendEffect.NavigateToReview) } + return + } + + Logger.info("No amount found in lnurlPay, proceeding to enter amount manually") + if (isMainScanner) { + showSheet(BottomSheetType.Send(SendRoute.Amount)) + } else { + setSendEffect(SendEffect.NavigateToAmount) } } @@ -690,8 +605,6 @@ class AppViewModel @Inject constructor( private suspend fun handleLightningInvoice( invoice: LightningInvoice, - uri: String, - lnUrlParameters: LnUrlParameters? = null ) { if (invoice.isExpired) { toast( @@ -701,12 +614,8 @@ class AppViewModel @Inject constructor( ) return } - // Check for QuickPay conditions - val quickPayHandled = handleQuickPayIfApplicable( - invoice = uri, - amountSats = invoice.amountSatoshis, - ) + val quickPayHandled = handleQuickPayIfApplicable(amountSats = invoice.amountSatoshis, invoice = invoice) if (quickPayHandled) return if (!lightningService.canSend(invoice.amountSatoshis)) { @@ -721,13 +630,11 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( amount = invoice.amountSatoshis, - bolt11 = uri, - description = invoice.description.orEmpty(), decodedInvoice = invoice, payMethod = SendMethod.LIGHTNING, - lnUrlParameters = lnUrlParameters ) } + if (invoice.amountSatoshis > 0uL) { Logger.info("Found amount in invoice, proceeding with payment") @@ -736,36 +643,53 @@ class AppViewModel @Inject constructor( } else { setSendEffect(SendEffect.NavigateToReview) } - } else { - Logger.info("No amount found in invoice, proceeding entering amount manually") - resetAmountInput() + return + } + Logger.info("No amount found in invoice, proceeding to enter amount manually") + resetAmountInput() - if (isMainScanner) { - showSheet(BottomSheetType.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + if (isMainScanner) { + showSheet(BottomSheetType.Send(SendRoute.Amount)) + } else { + setSendEffect(SendEffect.NavigateToAmount) } } private suspend fun handleQuickPayIfApplicable( - invoice: String, amountSats: ULong, + lnurlPay: LnurlPayData? = null, + invoice: LightningInvoice? = null, ): Boolean { val settings = settingsStore.data.first() if (!settings.isQuickPayEnabled || amountSats == 0uL) { return false } - val quickPayAmountSats = - currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + ?: return false if (amountSats <= quickPayAmountSats) { Logger.info("Using QuickPay: $amountSats sats <= $quickPayAmountSats sats threshold") + + val quickPayData: QuickPayData = when { + lnurlPay != null -> { + QuickPayData.LnurlPay(sats = amountSats, callback = lnurlPay.callback) + } + + else -> { + val decodedInvoice = requireNotNull(invoice) + QuickPayData.Bolt11(sats = amountSats, bolt11 = decodedInvoice.bolt11) + } + } + + _quickPayData.update { quickPayData } + + Logger.debug("QuickPayData: $quickPayData") + if (isMainScanner) { - showSheet(BottomSheetType.Send(SendRoute.QuickPay(invoice, amountSats.toLong()))) + showSheet(BottomSheetType.Send(SendRoute.QuickPay)) } else { - setSendEffect(SendEffect.NavigateToQuickPay(invoice, amountSats.toLong())) + setSendEffect(SendEffect.NavigateToQuickPay) } return true } @@ -824,7 +748,6 @@ class AppViewModel @Inject constructor( utxosToSpend = _sendUiState.value.utxosToSpend ).getOrNull() ?: return - if (totalFee > BigDecimal.valueOf(amountSats.toLong()) .times(BigDecimal(0.5)).toLong().toUInt() ) { @@ -851,6 +774,26 @@ class AppViewModel @Inject constructor( delay(300) // wait for screen transitions when applicable val amount = _sendUiState.value.amount + + val lnUrlParameters = _sendUiState.value.lnUrlParameters + val isLnurlPay = lnUrlParameters is LnUrlParameters.LnUrlPay + + if (isLnurlPay) { + lightningService.fetchLnurlInvoice( + callbackUrl = lnUrlParameters.data.callback, + amountSats = amount, + comment = _sendUiState.value.comment.takeIf { it.isNotEmpty() }, + ).onSuccess { invoice -> + _sendUiState.update { + it.copy(decodedInvoice = invoice) + } + }.onFailure { + toast(Exception("Error fetching lnurl invoice")) + hideSheet() + return + } + } + when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> { val address = _sendUiState.value.address @@ -893,17 +836,12 @@ class AppViewModel @Inject constructor( } SendMethod.LIGHTNING -> { - val bolt11 = _sendUiState.value.bolt11 - if (bolt11 == null) { - Logger.error("Null invoice", context = "AppViewModel") - toast(Exception("Couldn't find invoice")) - hideSheet() - return - } + val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) + val bolt11 = decodedInvoice.bolt11 + // Determine if we should override amount - val decodedInvoice = _sendUiState.value.decodedInvoice - val invoiceAmount = decodedInvoice?.amountSatoshis?.takeIf { it > 0uL } ?: amount - val paymentAmount = if (decodedInvoice?.amountSatoshis != null) invoiceAmount else null + val paymentAmount = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount + sendLightning(bolt11, paymentAmount).onSuccess { paymentHash -> Logger.info("Lightning send result payment hash: $paymentHash") val tags = _sendUiState.value.selectedTags @@ -945,7 +883,7 @@ class AppViewModel @Inject constructor( val invoice = lightningService.createInvoice( amountSats = _sendUiState.value.amount, description = lnUrlData.data.defaultDescription, - expirySeconds = 3600u + expirySeconds = 3600u, ).getOrNull() if (invoice == null) { @@ -1037,6 +975,17 @@ class AppViewModel @Inject constructor( return Env.TransactionDefaults.dustLimit.toULong() } + fun resetQuickPayData() = _quickPayData.update { null } + + fun clearClipboardForAutoRead() { + viewModelScope.launch { + val isAutoReadClipboardEnabled = settingsStore.data.first().enableAutoReadClipboard + if (isAutoReadClipboardEnabled) { + context.setClipboardText("") + } + } + } + fun resetSendState() { _sendUiState.value = SendUiState() scan = null @@ -1238,7 +1187,6 @@ data class SendUiState( val amount: ULong = 0u, val amountInput: String = "", val isAmountInputValid: Boolean = false, - val description: String = "", val isUnified: Boolean = false, val payMethod: SendMethod = SendMethod.ONCHAIN, val selectedTags: List = listOf(), @@ -1250,6 +1198,7 @@ data class SendUiState( val isLoading: Boolean = false, val speed: TransactionSpeed? = null, val utxosToSpend: List? = null, + val comment: String = "", ) enum class AmountWarning(@StringRes val message: Int) { @@ -1269,7 +1218,7 @@ sealed class SendEffect { data object NavigateToWithdrawConfirm : SendEffect() data object NavigateToWithdrawError : SendEffect() data object NavigateToCoinSelection : SendEffect() - data class NavigateToQuickPay(val invoice: String, val amount: Long) : SendEffect() + data object NavigateToQuickPay : SendEffect() data class PaymentSuccess(val sheet: NewTransactionSheetDetails? = null) : SendEffect() } @@ -1294,18 +1243,25 @@ sealed class SendEvent { data class CoinSelectionContinue(val utxos: List) : SendEvent() + data class CommentChange(val value: String) : SendEvent() + data object SwipeToPay : SendEvent() data object SpeedAndFee : SendEvent() data object PaymentMethodSwitch : SendEvent() - data object Reset : SendEvent() data object ConfirmAmountWarning : SendEvent() data object DismissAmountWarning : SendEvent() data object PayConfirmed : SendEvent() } sealed interface LnUrlParameters { - data class LnUrlPay(val data: LnurlPayData, val address: String) : LnUrlParameters - data class LnUrlAddress(val data: LnurlAddressData, val address: String) : LnUrlParameters + data class LnUrlPay(val data: LnurlPayData) : LnUrlParameters data class LnUrlWithdraw(val data: LnurlWithdrawData, val address: String) : LnUrlParameters } + +sealed interface QuickPayData { + val sats: ULong + + data class Bolt11(override val sats: ULong, val bolt11: String) : QuickPayData + data class LnurlPay(override val sats: ULong, val callback: String) : QuickPayData +} // endregion diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 1f5f151bf..16e44704b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -27,20 +27,38 @@ class QuickPayViewModel @Inject constructor( val lightningState = lightningRepo.lightningState - fun payInvoice(bolt11: String, amount: ULong? = null) { + fun pay(quickPayData: QuickPayData) { viewModelScope.launch { - val result = sendLightning(bolt11, amount) - if (result.isSuccess) { - Logger.info("QuickPay lightning payment successful") - _uiState.update { it.copy(result = QuickPayResult.Success) } - } else { - val error = result.exceptionOrNull() - Logger.error("QuickPay lightning payment failed", error) + val (bolt11, amount) = when (val data = quickPayData) { + is QuickPayData.Bolt11 -> { + Logger.info("QuickPay: processing bolt11 invoice") + data.bolt11 to data.sats + } - _uiState.update { - it.copy(result = QuickPayResult.Error(error?.message ?: "Payment failed")) + is QuickPayData.LnurlPay -> { + Logger.info("QuickPay: fetching LNURL Pay invoice from callback") + val invoice = lightningRepo.fetchLnurlInvoice(callbackUrl = data.callback, amountSats = data.sats) + .getOrElse { error -> + _uiState.update { + it.copy(result = QuickPayResult.Error(error.message.orEmpty())) + } + return@launch + } + invoice.bolt11 to quickPayData.sats } } + + sendLightning(bolt11, amount) + .onSuccess { + Logger.info("QuickPay lightning payment successful") + _uiState.update { it.copy(result = QuickPayResult.Success) } + }.onFailure { error -> + Logger.error("QuickPay lightning payment failed", error) + + _uiState.update { + it.copy(result = QuickPayResult.Error(error.message.orEmpty())) + } + } } } @@ -48,8 +66,7 @@ class QuickPayViewModel @Inject constructor( bolt11: String, amount: ULong? = null, ): Result { - val hash = lightningRepo.payInvoice(bolt11 = bolt11, sats = amount).getOrNull() - ?: return Result.failure(Exception("Failed to initiate payment")) + val hash = lightningRepo.payInvoice(bolt11 = bolt11, sats = amount).getOrThrow() // Wait until matching payment event is received val result = ldkNodeEventBus.events.watchUntil { event -> diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 5d702aaa3..a88bf9851 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -28,7 +28,7 @@ import to.bitkit.services.BlocktankNotificationsService import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService -import to.bitkit.services.LnUrlWithdrawService +import to.bitkit.services.LnurlService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -48,7 +48,7 @@ class LightningRepoTest : BaseUnitTest() { private val keychain: Keychain = mock() private val cacheStore: CacheStore = mock() - private val lnUrlWithdrawService: LnUrlWithdrawService = mock() + private val lnurlService: LnurlService = mock() @Before fun setUp() { @@ -62,7 +62,7 @@ class LightningRepoTest : BaseUnitTest() { blocktankNotificationsService = blocktankNotificationsService, firebaseMessaging = firebaseMessaging, keychain = keychain, - lnUrlWithdrawService = lnUrlWithdrawService, + lnurlService = lnurlService, cacheStore = cacheStore, ) } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 33f986815..3b175223d 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -1,8 +1,6 @@ package to.bitkit.repositories import app.cash.turbine.test -import com.synonym.bitkitcore.ActivityFilter -import com.synonym.bitkitcore.PaymentType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before @@ -18,8 +16,8 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking -import to.bitkit.data.AppDb import to.bitkit.data.AppCacheData +import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain @@ -203,6 +201,14 @@ class WalletRepoTest : BaseUnitTest() { } whenever(lightningRepo.getBalances()).thenReturn(balanceDetails) + val channels = listOf( + mock { + on { isUsable } doReturn true + on { nextOutboundHtlcLimitMsat } doReturn 1000uL + }, + ) + whenever(lightningRepo.getChannels()).thenReturn(channels) + sut.syncBalances() sut.balanceState.test { @@ -210,6 +216,7 @@ class WalletRepoTest : BaseUnitTest() { assertEquals(1500uL, state.totalSats) assertEquals(500uL, state.totalLightningSats) assertEquals(1000uL, state.totalOnchainSats) + assertEquals(1uL, state.maxSendLightningSats) cancelAndIgnoreRemainingEvents() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f56241c68..de1fbaf28 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,7 +47,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } #bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.3" } # mavenLocal -bitkitcore = { module = "com.github.synonymdev:bitkit-core", version = "v0.1.7" } # jitpack +bitkitcore = { module = "com.github.synonymdev:bitkit-core", version = "v0.1.8" } # jitpack bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } @@ -86,7 +86,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } #ldk-node-android = { module = "org.lightningdevkit:ldk-node-android", version = "0.6.1" } # upstream -ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "v0.6.1-rc.2" } # fork +ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "v0.6.1-rc.3" } # fork lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }