diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 530de7f4f..516944bcb 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -109,6 +109,34 @@ fun Long.toRelativeTimeString( } } +fun formatInvoiceExpiryRelative( + expirySeconds: ULong, + locale: Locale = Locale.getDefault(), +): String { + val seconds = expirySeconds.toLong() + if (seconds <= 0) return "" + + val uLocale = ULocale.forLocale(locale) + val numberFormat = NumberFormat.getNumberInstance(uLocale)?.apply { maximumFractionDigits = 0 } + val formatter = RelativeDateTimeFormatter.getInstance( + uLocale, + numberFormat, + RelativeDateTimeFormatter.Style.LONG, + DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE, + ) ?: return "" + + val minutes = seconds / Factor.SECONDS_TO_MINUTES.toLong() + val hours = minutes / Factor.MINUTES_TO_HOURS.toLong() + val days = hours / Factor.HOURS_TO_DAYS.toLong() + + return when { + minutes < 1 -> formatter.format(seconds.toDouble(), Direction.NEXT, RelativeUnit.SECONDS) + hours < 1 -> formatter.format(minutes.toDouble(), Direction.NEXT, RelativeUnit.MINUTES) + days < 1 -> formatter.format(hours.toDouble(), Direction.NEXT, RelativeUnit.HOURS) + else -> formatter.format(days.toDouble(), Direction.NEXT, RelativeUnit.DAYS) + } +} + fun getDaysInMonth(month: LocalDate): List { val firstDayOfMonth = LocalDate(month.year, month.month, Constants.FIRST_DAY_OF_MONTH) val daysInMonth = month.month.toJavaMonth().length(isLeapYear(month.year)) diff --git a/app/src/main/java/to/bitkit/models/FeeRate.kt b/app/src/main/java/to/bitkit/models/FeeRate.kt index d4a8785de..6ebef7907 100644 --- a/app/src/main/java/to/bitkit/models/FeeRate.kt +++ b/app/src/main/java/to/bitkit/models/FeeRate.kt @@ -15,6 +15,13 @@ enum class FeeRate( @DrawableRes val icon: Int, val color: Color, ) { + INSTANT( + title = R.string.fee__instant__title, + description = R.string.fee__instant__description, + shortDescription = R.string.fee__instant__shortDescription, + color = Colors.Purple, + icon = R.drawable.ic_lightning, + ), FAST( title = R.string.fee__fast__title, description = R.string.fee__fast__description, @@ -53,7 +60,7 @@ enum class FeeRate( fun toSpeed(): TransactionSpeed { return when (this) { - FAST -> TransactionSpeed.Fast + INSTANT, FAST -> TransactionSpeed.Fast NORMAL -> TransactionSpeed.Medium MINIMUM, SLOW -> TransactionSpeed.Slow CUSTOM -> TransactionSpeed.Custom(0u) diff --git a/app/src/main/java/to/bitkit/ui/components/SendSectionView.kt b/app/src/main/java/to/bitkit/ui/components/SendSectionView.kt new file mode 100644 index 000000000..cf9c772fc --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/SendSectionView.kt @@ -0,0 +1,24 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import to.bitkit.ui.theme.Colors + +@Composable +fun SendSectionView( + caption: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + Caption13Up(text = caption, color = Colors.White64) + VerticalSpacer(8.dp) + content() + VerticalSpacer(16.dp) + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + } +} 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 698020593..3557e0105 100644 --- a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt +++ b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt @@ -23,6 +23,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -61,6 +63,7 @@ private val Padding = 8.dp @Composable fun SwipeToConfirm( + modifier: Modifier = Modifier, text: String = stringResource(R.string.other__swipe), color: Color = Colors.Brand, icon: ImageVector = Icons.AutoMirrored.Default.ArrowForward, @@ -68,8 +71,8 @@ fun SwipeToConfirm( endIconTint: Color = Colors.Black, loading: Boolean = false, confirmed: Boolean = false, + progress: MutableFloatState? = null, onConfirm: () -> Unit, - modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() val trailColor = remember(color) { color.copy(alpha = 0.24f) } @@ -94,6 +97,10 @@ fun SwipeToConfirm( ) } + SideEffect { + progress?.floatValue = (panX.value / maxPanX).coerceIn(0f, 1f) + } + Box( modifier = modifier .requiredHeight(CircleSize + Padding * 2) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index e65076a24..3f1f8f77d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -1,5 +1,6 @@ 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 @@ -22,7 +23,9 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -30,6 +33,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -49,9 +54,8 @@ 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.commentAllowed -import to.bitkit.ext.formatted +import to.bitkit.ext.formatInvoiceExpiryRelative import to.bitkit.models.FeeRate import to.bitkit.models.TransactionSpeed import to.bitkit.ui.components.BalanceHeaderView @@ -61,7 +65,9 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SendSectionView import to.bitkit.ui.components.SwipeToConfirm import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.components.TagButton @@ -84,7 +90,8 @@ import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.SendFee import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState -import java.time.Instant + +private const val EXPIRY_REFRESH_INTERVAL = 60_000L @Suppress("MagicNumber") @Composable @@ -173,6 +180,7 @@ private fun Content( showBiometrics: Boolean, modifier: Modifier = Modifier, canGoBack: Boolean = true, + initialShowDetails: Boolean = false, onBack: () -> Unit = {}, onEvent: (SendEvent) -> Unit = {}, onClickAddTag: () -> Unit = {}, @@ -203,8 +211,9 @@ private fun Content( if (isNodeRunning) { ContentRunning( uiState = uiState, - onEvent = onEvent, isLoading = isLoading, + initialShowDetails = initialShowDetails, + onEvent = onEvent, onClickAddTag = onClickAddTag, onClickTag = onClickTag, onSwipeToConfirm = onSwipeToConfirm, @@ -245,16 +254,26 @@ private fun Content( } } +@Suppress("MagicNumber") @Composable fun ContentRunning( uiState: SendUiState, - onEvent: (SendEvent) -> Unit, isLoading: Boolean, - onClickAddTag: () -> Unit, - onClickTag: (String) -> Unit, - onSwipeToConfirm: () -> Unit, modifier: Modifier = Modifier, + initialShowDetails: Boolean = false, + onEvent: (SendEvent) -> Unit = {}, + onClickAddTag: () -> Unit = {}, + onClickTag: (String) -> Unit = {}, + onSwipeToConfirm: () -> Unit = {}, ) { + var showDetails by rememberSaveable { mutableStateOf(initialShowDetails) } + val swipeProgress = remember { mutableFloatStateOf(0f) } + + val accentColor = when (uiState.payMethod) { + SendMethod.ONCHAIN -> Colors.Brand + SendMethod.LIGHTNING -> Colors.Purple + } + Column( modifier = modifier .padding(horizontal = 16.dp) @@ -271,32 +290,73 @@ fun ContentRunning( .testTag("ReviewAmount") ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(44.dp) - when (uiState.payMethod) { - SendMethod.ONCHAIN -> OnChainDescription(uiState = uiState, onEvent = onEvent) - SendMethod.LIGHTNING -> LightningDescription(uiState = uiState, onEvent = onEvent) - } + if (showDetails) { + when (uiState.payMethod) { + SendMethod.ONCHAIN -> OnChainDetails(uiState = uiState, onEvent = onEvent) + SendMethod.LIGHTNING -> LightningDetails(uiState = uiState, onEvent = onEvent) + } - if (uiState.lnurl is LnurlParams.LnurlPay) { - if (uiState.lnurl.data.commentAllowed()) { - LnurlCommentSection(uiState, onEvent) + if (uiState.lnurl is LnurlParams.LnurlPay) { + if (uiState.lnurl.data.commentAllowed()) { + LnurlCommentSection(uiState, onEvent) + } + } else { + TagsSection(uiState, onClickTag, onClickAddTag) } } else { - TagsSection(uiState, onClickTag, onClickAddTag) + Image( + painter = painterResource(R.drawable.coin_stack_4), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth(0.8f) + .align(Alignment.CenterHorizontally) + .padding(bottom = 16.dp) + .graphicsLayer { rotationZ = swipeProgress.floatValue * 14f } + ) } - FillHeight() VerticalSpacer(16.dp) + PrimaryButton( + text = stringResource( + if (showDetails) R.string.common__hide_details else R.string.common__show_details + ), + size = ButtonSize.Small, + onClick = { showDetails = !showDetails }, + icon = { + Icon( + painter = painterResource( + if (showDetails) { + R.drawable.ic_eye_slash + } else { + when (uiState.payMethod) { + SendMethod.ONCHAIN -> R.drawable.ic_speed_normal + SendMethod.LIGHTNING -> R.drawable.ic_lightning + } + } + ), + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(16.dp) + ) + }, + fullWidth = false, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag("SendConfirmToggleDetails") + ) + + FillHeight(min = 16.dp) + SwipeToConfirm( text = stringResource(R.string.wallet__send_swipe), - color = when (uiState.payMethod) { - SendMethod.ONCHAIN -> Colors.Brand - SendMethod.LIGHTNING -> Colors.Purple - }, + color = accentColor, loading = isLoading, confirmed = isLoading, + progress = swipeProgress, onConfirm = onSwipeToConfirm, ) VerticalSpacer(16.dp) @@ -366,23 +426,47 @@ private fun TagsSection( } @Composable -private fun OnChainDescription( +private fun OnChainDetails( uiState: SendUiState, onEvent: (SendEvent) -> Unit, ) { - val fee by remember(uiState.speed) { mutableStateOf(FeeRate.fromSpeed(uiState.speed)) } - Column(modifier = Modifier.fillMaxWidth()) { - Caption13Up(text = stringResource(R.string.wallet__send_to), color = Colors.White64) - Spacer(modifier = Modifier.height(8.dp)) - BodySSB( - text = uiState.address, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - modifier = Modifier - .clickableAlpha { onEvent(SendEvent.NavToAddress) } - .testTag("ReviewUri") - ) - HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) + val fee = remember(uiState.speed) { FeeRate.fromSpeed(uiState.speed) } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + SendSectionView( + caption = stringResource(R.string.wallet__send_from), + modifier = Modifier.weight(1f) + ) { + NumberPadActionButton( + text = stringResource(R.string.wallet__savings__title), + color = Colors.Brand, + enabled = uiState.canSwitchWallet, + icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchWallet }, + onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, + modifier = Modifier.testTag("SendConfirmAssetButton") + ) + } + SendSectionView( + caption = stringResource(R.string.wallet__send_to), + modifier = Modifier.weight(1f) + ) { + BodySSB( + text = uiState.address, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier + .height(28.dp) + .clickableAlpha { onEvent(SendEvent.NavToAddress) } + .testTag("ReviewUri") + ) + } + } Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -390,17 +474,11 @@ private fun OnChainDescription( ) { Column( modifier = Modifier - .fillMaxHeight() .weight(1f) + .fillMaxHeight() + .clickableAlpha { onEvent(SendEvent.SpeedAndFee) } ) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickableAlpha { onEvent(SendEvent.SpeedAndFee) } - ) { - VerticalSpacer(16.dp) - Caption13Up(stringResource(R.string.wallet__send_fee_and_speed), color = Colors.White64) - VerticalSpacer(8.dp) + SendSectionView(caption = stringResource(R.string.wallet__send_fee_and_speed)) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -432,127 +510,130 @@ private fun OnChainDescription( modifier = Modifier.size(16.dp) ) } - FillHeight() - VerticalSpacer(16.dp) } - HorizontalDivider() } - Column( - modifier = Modifier - .fillMaxHeight() - .weight(1f) + SendSectionView( + caption = stringResource(R.string.wallet__send_confirming_in), + modifier = Modifier.weight(1f) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickableAlpha { onEvent(SendEvent.SpeedAndFee) } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - VerticalSpacer(16.dp) - Caption13Up(text = stringResource(R.string.wallet__send_confirming_in), color = Colors.White64) - Spacer(modifier = Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - painterResource(R.drawable.ic_clock), - contentDescription = null, - tint = Colors.Brand, - modifier = Modifier.size(16.dp) - ) - BodySSB(stringResource(fee.description)) - } - FillHeight() - VerticalSpacer(16.dp) + Icon( + painterResource(R.drawable.ic_clock), + contentDescription = null, + tint = Colors.Brand, + modifier = Modifier.size(16.dp) + ) + BodySSB(stringResource(fee.description)) } - HorizontalDivider() } } } } +@Suppress("CyclomaticComplexMethod") @Composable -private fun LightningDescription( +private fun LightningDetails( uiState: SendUiState, onEvent: (SendEvent) -> Unit, ) { val isLnurlPay = uiState.lnurl is LnurlParams.LnurlPay val expirySeconds = uiState.decodedInvoice?.expirySeconds val description = uiState.decodedInvoice?.description + val destination = when { + isLnurlPay -> (uiState.lnurl as LnurlParams.LnurlPay).data.uri + else -> uiState.decodedInvoice?.bolt11.orEmpty() + } - Column(modifier = Modifier.fillMaxWidth()) { - Caption13Up( - text = stringResource(R.string.wallet__send_invoice), - color = Colors.White64, - ) - val destination = when { - isLnurlPay -> uiState.lnurl.data.uri - else -> uiState.decodedInvoice?.bolt11.orEmpty() + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + SendSectionView( + caption = stringResource(R.string.wallet__send_from), + modifier = Modifier.weight(1f) + ) { + NumberPadActionButton( + text = stringResource(R.string.wallet__spending__title), + color = Colors.Purple, + enabled = uiState.canSwitchWallet, + icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchWallet }, + onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, + modifier = Modifier.testTag("SendConfirmAssetButton") + ) + } + SendSectionView( + caption = stringResource(R.string.wallet__send_to), + modifier = Modifier.weight(1f) + ) { + BodySSB( + text = destination, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier + .height(28.dp) + .clickableAlpha { onEvent(SendEvent.NavToAddress) } + .testTag("ReviewUri") + ) + } } - Spacer(modifier = Modifier.height(8.dp)) - BodySSB( - text = destination, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - modifier = Modifier - .clickableAlpha { onEvent(SendEvent.NavToAddress) } - .testTag("ReviewUri") - ) - HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) - Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.height(IntrinsicSize.Min) ) { Column( modifier = Modifier - .fillMaxHeight() .weight(1f) + .fillMaxHeight() + .let { if (uiState.canSwitchWallet) it.clickableAlpha { onEvent(SendEvent.SpeedAndFee) } else it } ) { - VerticalSpacer(16.dp) - Caption13Up(text = stringResource(R.string.wallet__send_fee_and_speed), color = Colors.White64) - Spacer(modifier = Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - painterResource(R.drawable.ic_lightning), - contentDescription = null, - tint = Colors.Purple, - modifier = Modifier.size(16.dp) - ) - (uiState.fee as? SendFee.Lightning)?.value - ?.takeIf { it > 0 } - ?.let { feeSat -> - val feeText = let { - val prefix = stringResource(R.string.fee__instant__title) - val value = rememberMoneyText(feeSat, showSymbol = true) - "$prefix (± $value)" - } - BodySSB( - text = feeText.withAccent(accentColor = Colors.White), - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, + SendSectionView(caption = stringResource(R.string.wallet__send_fee_and_speed)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + painterResource(R.drawable.ic_lightning), + contentDescription = null, + tint = Colors.Purple, + modifier = Modifier.size(16.dp) + ) + (uiState.fee as? SendFee.Lightning)?.value + ?.takeIf { it > 0 } + ?.let { feeSat -> + val feeText = let { + val prefix = stringResource(R.string.fee__instant__title) + val value = rememberMoneyText(feeSat, showSymbol = true) + "$prefix (± $value)" + } + BodySSB( + text = feeText.withAccent(accentColor = Colors.White), + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } ?: BodySSB(text = stringResource(R.string.fee__instant__title)) + if (uiState.canSwitchWallet) { + Icon( + painterResource(R.drawable.ic_pencil_simple), + contentDescription = null, + modifier = Modifier.size(16.dp) ) - } ?: BodySSB(text = stringResource(R.string.fee__instant__title)) + } + } } - Spacer(modifier = Modifier.weight(1f)) - HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) } if (!isLnurlPay && expirySeconds != null) { - Column( - modifier = Modifier - .fillMaxHeight() - .weight(1f) - .padding(top = 16.dp) + SendSectionView( + caption = stringResource(R.string.wallet__send_invoice_expiration), + modifier = Modifier.weight(1f), ) { - Caption13Up( - text = stringResource(R.string.wallet__send_invoice_expiration), - color = Colors.White64, - ) - Spacer(modifier = Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -563,23 +644,25 @@ private fun LightningDescription( tint = Colors.Purple, modifier = Modifier.size(16.dp) ) - val invoiceExpiryTimestamp = Instant.now().plusSeconds(expirySeconds.toLong()) - .formatted(DatePattern.INVOICE_EXPIRY) - - BodySSB(text = invoiceExpiryTimestamp) + val timestampSeconds = uiState.decodedInvoice?.timestampSeconds ?: 0uL + val invoiceExpiryText by produceState("", timestampSeconds, expirySeconds) { + val expiryMoment = timestampSeconds + expirySeconds + while (true) { + val now = System.currentTimeMillis() / 1000 + val remaining = (expiryMoment.toLong() - now).coerceAtLeast(0) + value = formatInvoiceExpiryRelative(remaining.toULong()) + delay(EXPIRY_REFRESH_INTERVAL) + } + } + BodySSB(text = invoiceExpiryText) } - Spacer(modifier = Modifier.weight(1f)) - HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) } } } if (!isLnurlPay && description != null) { - Column { - Caption13Up(text = stringResource(R.string.wallet__note), color = Colors.White64) - Spacer(modifier = Modifier.height(8.dp)) - BodySSB(text = description) - HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) + SendSectionView(caption = stringResource(R.string.wallet__note)) { + BodySSB(text = description, maxLines = 1) } } } @@ -623,6 +706,51 @@ private fun PreviewOnChain() { } } +@Suppress("MagicNumber") +@Preview(showSystemUi = true, group = "onchain details") +@Composable +private fun PreviewOnChainDetails() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = sendUiState().copy( + selectedTags = persistentListOf("car", "house", "uber"), + fee = SendFee.OnChain(1_234), + speed = TransactionSpeed.Medium, + ), + isNodeRunning = true, + isLoading = false, + showBiometrics = false, + initialShowDetails = true, + modifier = Modifier.sheetHeight(), + ) + } + } +} + +@Suppress("MagicNumber") +@Preview(showSystemUi = true, group = "lightning details") +@Composable +private fun PreviewLightningDetails() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = sendUiState().copy( + amount = 6_543u, + payMethod = SendMethod.LIGHTNING, + selectedTags = persistentListOf("coffee"), + fee = SendFee.Lightning(43), + ), + isNodeRunning = true, + isLoading = false, + showBiometrics = false, + initialShowDetails = true, + modifier = Modifier.sheetHeight(), + ) + } + } +} + @Suppress("MagicNumber") @Preview(showSystemUi = true, group = "onchain", device = Devices.NEXUS_5) @Composable diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeRateScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeRateScreen.kt index fe3326848..c784b6ace 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeRateScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeRateScreen.kt @@ -47,15 +47,17 @@ import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState @Composable fun SendFeeRateScreen( sendUiState: SendUiState, + viewModel: SendFeeViewModel, onBack: () -> Unit, onContinue: () -> Unit, onSelect: (TransactionSpeed) -> Unit, - viewModel: SendFeeViewModel, + onSelectInstant: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -65,9 +67,14 @@ fun SendFeeRateScreen( Content( uiState = uiState, + isUnified = sendUiState.isUnified, + payMethod = sendUiState.payMethod, + estimatedRoutingFee = sendUiState.estimatedRoutingFee.toLong(), onBack = onBack, onContinue = onContinue, - onSelect = { onSelect(it.toSpeed()) }, + onSelect = { + if (it == FeeRate.INSTANT) onSelectInstant() else onSelect(it.toSpeed()) + }, ) } @@ -75,6 +82,9 @@ fun SendFeeRateScreen( private fun Content( uiState: SendFeeUiState, modifier: Modifier = Modifier, + isUnified: Boolean = false, + payMethod: SendMethod = SendMethod.ONCHAIN, + estimatedRoutingFee: Long = 0L, onBack: () -> Unit = {}, onContinue: () -> Unit = {}, onSelect: (FeeRate) -> Unit = {}, @@ -103,11 +113,22 @@ private fun Content( title = stringResource(R.string.wallet__send_fee_and_speed), modifier = Modifier.padding(horizontal = 16.dp) ) + + if (isUnified) { + FeeItem( + feeRate = FeeRate.INSTANT, + sats = estimatedRoutingFee, + isSelected = payMethod == SendMethod.LIGHTNING, + onClick = { onSelect(FeeRate.INSTANT) }, + modifier = Modifier.testTag("fee_INSTANT_button") + ) + } + uiState.fees.map { (feeRate, sats) -> FeeItem( feeRate = feeRate, sats = sats, - isSelected = uiState.selected == feeRate, + isSelected = uiState.selected == feeRate && payMethod == SendMethod.ONCHAIN, isDisabled = feeRate in uiState.disabledRates, onClick = { if (feeRate !in uiState.disabledRates) onSelect(feeRate) }, modifier = Modifier.testTag("fee_${feeRate.name}_button"), @@ -224,6 +245,31 @@ private fun PreviewCustom() { } } +@Suppress("MagicNumber") +@Preview(showSystemUi = true) +@Composable +private fun PreviewWithInstant() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = SendFeeUiState( + fees = persistentMapOf( + FeeRate.FAST to 4000L, + FeeRate.NORMAL to 3000L, + FeeRate.SLOW to 2000L, + FeeRate.CUSTOM to 0L, + ), + selected = FeeRate.NORMAL, + ), + isUnified = true, + payMethod = SendMethod.LIGHTNING, + estimatedRoutingFee = 43L, + modifier = Modifier.sheetHeight(), + ) + } + } +} + @Preview(showSystemUi = true) @Composable private fun PreviewEmpty() { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt index fd3e4cf6e..ecf43247d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt @@ -13,10 +13,11 @@ import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R -import to.bitkit.ext.getSatsPerVByteFor +import to.bitkit.data.SettingsStore import to.bitkit.models.FeeRate import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -37,6 +38,7 @@ class SendFeeViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val currencyRepo: CurrencyRepo, private val walletRepo: WalletRepo, + private val settingsStore: SettingsStore, @ApplicationContext private val context: Context, ) : ViewModel() { private val _uiState = MutableStateFlow(SendFeeUiState()) @@ -52,25 +54,31 @@ class SendFeeViewModel @Inject constructor( val selected = FeeRate.fromSpeed(sendUiState.speed) val fees = sendUiState.fees - val custom = when (val speed = sendUiState.speed) { - is TransactionSpeed.Custom -> speed - else -> { - val satsPerVByte = sendUiState.feeRates?.getSatsPerVByteFor(speed) ?: 0u - TransactionSpeed.Custom(satsPerVByte) + viewModelScope.launch { + val custom = when (val speed = sendUiState.speed) { + is TransactionSpeed.Custom -> speed + else -> { + val settingsSpeed = settingsStore.data.first().defaultTransactionSpeed + val satsPerVByte = when (settingsSpeed) { + is TransactionSpeed.Custom -> settingsSpeed.satsPerVByte + else -> sendUiState.feeRates?.slow ?: 1u + } + TransactionSpeed.Custom(satsPerVByte) + } } + calculateMaxSatPerVByte() + val disabledRates = fees.filter { it.value.toULong() > maxFee }.keys.toImmutableSet() + _uiState.update { + it.copy( + selected = selected, + fees = fees, + custom = custom, + input = custom.satsPerVByte.toString().takeIf { custom.satsPerVByte > 0u } ?: "", + disabledRates = disabledRates, + ) + } + updateTotalFeeText() } - calculateMaxSatPerVByte() - val disabledRates = fees.filter { it.value.toULong() > maxFee }.keys.toImmutableSet() - _uiState.update { - it.copy( - selected = selected, - fees = fees, - custom = custom, - input = custom.satsPerVByte.toString().takeIf { custom.satsPerVByte > 0u } ?: "", - disabledRates = disabledRates, - ) - } - updateTotalFeeText() } private fun getFeeLimit(): ULong { diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 54f590811..9b9431d65 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -164,6 +164,10 @@ fun SendSheet( onBack = { navController.popBackStack() }, onContinue = { navController.popBackStack() }, onSelect = { speed -> appViewModel.onSelectSpeed(speed) }, + onSelectInstant = { + appViewModel.switchToLightning() + navController.popBackStack() + }, ) } composableWithDefaultTransitions { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f40c21b58..403ce9d85 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -931,6 +931,7 @@ class AppViewModel @Inject constructor( payMethod = SendMethod.LIGHTNING, ) } + updateCanSwitchWallet() return } @@ -1041,8 +1042,10 @@ class AppViewModel @Inject constructor( it.copy( amount = amount, isAmountInputValid = validateAmount(amount), + confirmedWarnings = persistentListOf(), ) } + updateCanSwitchWallet() } private fun onCommentChange(comment: String) { @@ -1080,6 +1083,7 @@ class AppViewModel @Inject constructor( } _sendUiState.update { it.copy( + payMethod = SendMethod.ONCHAIN, speed = speed, fee = SendFee.OnChain(fee), selectedUtxos = if (shouldResetUtxos) null else it.selectedUtxos, @@ -1090,17 +1094,59 @@ class AppViewModel @Inject constructor( } } + private fun updateCanSwitchWallet() { + val state = _sendUiState.value + if (!state.isUnified) { + _sendUiState.update { it.copy(canSwitchWallet = false) } + return + } + val amount = state.amount + val balance = walletRepo.balanceState.value + val canSwitch = amount >= Defaults.dustLimit.toULong() && + amount <= balance.maxSendOnchainSats && + amount <= balance.maxSendLightningSats + _sendUiState.update { it.copy(canSwitchWallet = canSwitch) } + } + private suspend fun onPaymentMethodSwitch() { - val nextPaymentMethod = when (_sendUiState.value.payMethod) { + val current = _sendUiState.value + if (!current.isUnified) return + + val nextMethod = when (current.payMethod) { SendMethod.ONCHAIN -> SendMethod.LIGHTNING SendMethod.LIGHTNING -> SendMethod.ONCHAIN } _sendUiState.update { it.copy( - payMethod = nextPaymentMethod, - isAmountInputValid = validateAmount(it.amount, nextPaymentMethod), + payMethod = nextMethod, + isAmountInputValid = validateAmount(it.amount, nextMethod), + confirmedWarnings = persistentListOf(), ) } + when (nextMethod) { + SendMethod.ONCHAIN -> { + val defaultSpeed = settingsStore.data.first().defaultTransactionSpeed + _sendUiState.update { it.copy(speed = defaultSpeed) } + refreshFeeEstimates() + } + SendMethod.LIGHTNING -> { + _sendUiState.update { it.copy(fee = SendFee.Lightning(0)) } + estimateLightningRoutingFeesIfNeeded() + } + } + } + + fun switchToLightning() { + viewModelScope.launch { + _sendUiState.update { + it.copy( + payMethod = SendMethod.LIGHTNING, + fee = SendFee.Lightning(0), + isAmountInputValid = validateAmount(it.amount, SendMethod.LIGHTNING), + ) + } + estimateLightningRoutingFeesIfNeeded() + } } private suspend fun onAmountContinue() { @@ -2465,6 +2511,7 @@ data class SendUiState( val amount: ULong = 0u, val isAmountInputValid: Boolean = false, val isUnified: Boolean = false, + val canSwitchWallet: Boolean = false, val payMethod: SendMethod = SendMethod.ONCHAIN, val selectedTags: ImmutableList = persistentListOf(), val decodedInvoice: LightningInvoice? = null, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d409e9f2..24f968538 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,6 +44,7 @@ Empty Error Unknown error + Hide Details Later Learn More Max @@ -60,6 +61,7 @@ Save Search Share + Show Details Skip Success Try Again @@ -73,6 +75,8 @@ ±10-20 minutes ±10m Fast + Lightning Network + Instant Instant +2 hours +2h @@ -958,6 +962,7 @@ Speed ₿ {feeSats} for this transaction ₿ {feeSats} for this transaction ({fiatSymbol}{fiatFormatted}) + Send from Invoice Invoice expiration MAX diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt index 9db528317..4d2529018 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt @@ -5,12 +5,15 @@ import com.synonym.bitkitcore.FeeRates import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.models.BalanceState import to.bitkit.models.FeeRate import to.bitkit.models.TransactionSpeed @@ -29,6 +32,7 @@ class SendFeeViewModelTest : BaseUnitTest() { private val lightningRepo: LightningRepo = mock() private val currencyRepo: CurrencyRepo = mock() private val walletRepo: WalletRepo = mock() + private val settingsStore: SettingsStore = mock() private val context: Context = mock() private val balance = 100_000uL @@ -44,7 +48,8 @@ class SendFeeViewModelTest : BaseUnitTest() { whenever(walletRepo.balanceState) .thenReturn(MutableStateFlow(BalanceState(totalOnchainSats = balance))) - sut = SendFeeViewModel(lightningRepo, currencyRepo, walletRepo, context) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + sut = SendFeeViewModel(lightningRepo, currencyRepo, walletRepo, settingsStore, context) } @Test