diff --git a/app/src/main/java/to/bitkit/ui/components/QrCodeImage.kt b/app/src/main/java/to/bitkit/ui/components/QrCodeImage.kt index e3e8af358..2019b5ba7 100644 --- a/app/src/main/java/to/bitkit/ui/components/QrCodeImage.kt +++ b/app/src/main/java/to/bitkit/ui/components/QrCodeImage.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -112,6 +113,13 @@ fun QrCodeImage( } else { imageComposable() } + } else { + Image( + painter = painterResource(R.drawable.qr_placeholder), + contentDescription = content, + contentScale = ContentScale.Inside, + modifier = Modifier.fillMaxSize() + ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index c99a213f7..a6558b341 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -1,7 +1,6 @@ package to.bitkit.ui.screens.wallets.activity import androidx.activity.compose.BackHandler -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -12,11 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -33,6 +28,7 @@ import to.bitkit.ui.screens.wallets.activity.components.ActivityListFilter import to.bitkit.ui.screens.wallets.activity.components.ActivityListGrouped import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.shared.modifiers.swipeToChangeTab import to.bitkit.ui.shared.util.screen import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.viewmodels.ActivityListViewModel @@ -146,30 +142,6 @@ private fun AllActivityScreenContent( } } -private fun Modifier.swipeToChangeTab(currentTabIndex: Int, tabCount: Int, onTabChange: (Int) -> Unit) = composed { - val threshold = remember { 1500f } - val velocityTracker = remember { VelocityTracker() } - - pointerInput(currentTabIndex) { - detectHorizontalDragGestures( - onHorizontalDrag = { change, _ -> - velocityTracker.addPosition(change.uptimeMillis, change.position) - }, - onDragEnd = { - val velocity = velocityTracker.calculateVelocity().x - when { - velocity >= threshold && currentTabIndex > 0 -> onTabChange(currentTabIndex - 1) - velocity <= -threshold && currentTabIndex < tabCount - 1 -> onTabChange(currentTabIndex + 1) - } - velocityTracker.resetTracking() - }, - onDragCancel = { - velocityTracker.resetTracking() - }, - ) - } -} - @Preview(showSystemUi = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListFilter.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListFilter.kt index 03fa3ec57..d78bbbb46 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListFilter.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListFilter.kt @@ -100,10 +100,10 @@ fun ActivityListFilter( } } -enum class ActivityTab { +enum class ActivityTab : TabItem { ALL, SENT, RECEIVED, OTHER; - val uiText: String + override val uiText: String @Composable get() = when (this) { ALL -> stringResource(R.string.wallet__activity_tabs__all) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt index 4d69be23c..9f90a8739 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,18 +18,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.shared.util.clickableAlpha import to.bitkit.ui.theme.Colors @Composable -fun CustomTabRowWithSpacing( - tabs: List, +fun CustomTabRowWithSpacing( + tabs: List, currentTabIndex: Int, - onTabChange: (ActivityTab) -> Unit, + onTabChange: (T) -> Unit, modifier: Modifier = Modifier, + selectedColor: Color = Colors.Brand, ) { Column(modifier = modifier) { Row( @@ -47,7 +49,7 @@ fun CustomTabRowWithSpacing( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxWidth() - .clickable { onTabChange(tab) } + .clickableAlpha { onTabChange(tab) } .padding(vertical = 8.dp) .testTag("Tab-${tab.name.lowercase()}"), ) { @@ -59,16 +61,21 @@ fun CustomTabRowWithSpacing( ) } - // Animated indicator val animatedAlpha by animateFloatAsState( targetValue = if (isSelected) 1f else 0.2f, - animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + animationSpec = tween( + durationMillis = 250, + easing = FastOutSlowInEasing + ), label = "indicatorAlpha" ) val animatedColor by animateColorAsState( - targetValue = if (isSelected) Colors.Brand else Colors.White, - animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + targetValue = if (isSelected) selectedColor else Colors.White, + animationSpec = tween( + durationMillis = 250, + easing = FastOutSlowInEasing + ), label = "indicatorColor" ) @@ -88,3 +95,9 @@ fun CustomTabRowWithSpacing( } } } + +interface TabItem { + val name: String + val uiText: String + @Composable get +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt new file mode 100644 index 000000000..e5ac5e324 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt @@ -0,0 +1,82 @@ +package to.bitkit.ui.screens.wallets.receive + +import to.bitkit.R + +/** + * Returns the appropriate invoice/address for the selected tab. + * + * @param tab The selected receive tab + * @param bip21 Full BIP21 invoice (onchain + lightning) + * @param bolt11 Lightning invoice + * @param cjitInvoice CJIT invoice from Blocktank (if active) + * @param onchainAddress Pure Bitcoin address (fallback) + * @return The invoice string to display/encode in QR + */ +fun getInvoiceForTab( + tab: ReceiveTab, + bip21: String, + bolt11: String, + cjitInvoice: String?, + isNodeRunning: Boolean, + onchainAddress: String, +): String { + return when (tab) { + ReceiveTab.SAVINGS -> { + // Return BIP21 without lightning parameter to preserve amount and other parameters + removeLightningFromBip21(bip21, onchainAddress) + } + + ReceiveTab.AUTO -> { + bip21.takeIf { isNodeRunning && containsLightningParameter(bip21) }.orEmpty() + } + + ReceiveTab.SPENDING -> { + // Lightning only: prefer CJIT > bolt11 + cjitInvoice?.takeIf { it.isNotEmpty() && isNodeRunning } + ?: bolt11 + } + } +} + +/** + * Removes the lightning parameter from a BIP21 URI while preserving all other parameters. + * + * @param bip21 Full BIP21 URI (e.g., bitcoin:address?amount=0.001&lightning=lnbc...) + * @param fallbackAddress Fallback address if BIP21 is empty or invalid + * @return BIP21 URI without the lightning parameter (e.g., bitcoin:address?amount=0.001) + */ +fun removeLightningFromBip21(bip21: String, fallbackAddress: String): String { + if (bip21.isBlank()) return fallbackAddress + + // Remove lightning parameter using regex + // Handles both "?lightning=..." and "&lightning=..." cases + val withoutLightning = bip21 + .replace(Regex("[?&]lightning=[^&]*"), "") + .replace(Regex("\\?$"), "") // Remove trailing ? if it's the last char + + return withoutLightning.ifBlank { fallbackAddress } +} + +/** + * Checks if a BIP21 URI contains a lightning parameter. + * + * @param bip21 The BIP21 URI to check + * @return true if the URI contains a lightning parameter, false otherwise + */ +private fun containsLightningParameter(bip21: String): Boolean { + return Regex("[?&]lightning=[^&]*").containsMatchIn(bip21) +} + +/** + * Returns the appropriate QR code logo resource for the selected tab. + * + * @param tab The selected receive tab + * @return Drawable resource ID for QR logo + */ +fun getQrLogoResource(tab: ReceiveTab): Int { + return when (tab) { + ReceiveTab.SAVINGS -> R.drawable.ic_btc_circle + ReceiveTab.AUTO -> R.drawable.ic_unified_circle + ReceiveTab.SPENDING -> R.drawable.ic_ln_circle + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 8304d608c..36c04c85f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -2,10 +2,16 @@ package to.bitkit.ui.screens.wallets.receive import android.graphics.Bitmap import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.snapping.SnapPosition +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -13,36 +19,40 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.Switch import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.keepScreenOn import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.accompanist.pager.HorizontalPagerIndicator +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.ext.truncate @@ -52,46 +62,115 @@ import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.Headline +import to.bitkit.ui.components.Display import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.QrCodeImage import to.bitkit.ui.components.Tooltip +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.wallets.activity.components.CustomTabRowWithSpacing +import to.bitkit.ui.screens.wallets.receive.ReceiveTab.AUTO +import to.bitkit.ui.screens.wallets.receive.ReceiveTab.SAVINGS +import to.bitkit.ui.screens.wallets.receive.ReceiveTab.SPENDING import to.bitkit.ui.shared.effects.SetMaxBrightness import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.shared.util.shareQrCode import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.theme.AppShapes -import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.MainUiState +@OptIn(FlowPreview::class) @Composable fun ReceiveQrScreen( - cjitInvoice: MutableState, - cjitActive: MutableState, + cjitInvoice: String?, walletState: MainUiState, - onCjitToggle: (Boolean) -> Unit, onClickEditInvoice: () -> Unit, - onClickReceiveOnSpending: () -> Unit, + onClickReceiveCjit: () -> Unit, modifier: Modifier = Modifier, + initialTab: ReceiveTab? = null, ) { SetMaxBrightness() - val qrLogoImageRes by remember(walletState, cjitInvoice.value) { - val resId = when { - cjitInvoice.value?.isNotEmpty() == true -> R.drawable.ic_ln_circle - walletState.bolt11.isNotEmpty() && walletState.onchainAddress.isNotEmpty() -> R.drawable.ic_unified_circle - else -> R.drawable.ic_btc_circle + val haptic = LocalHapticFeedback.current + val hasUsableChannels = walletState.channels.any { it.isUsable } + + var showDetails by remember { mutableStateOf(false) } + + val visibleTabs = remember(hasUsableChannels) { + buildList { + add(ReceiveTab.SAVINGS) + if (hasUsableChannels) { + add(ReceiveTab.AUTO) + } + add(ReceiveTab.SPENDING) + } + } + + val invoicesByTab = remember( + visibleTabs, + walletState.bip21, + walletState.bolt11, + walletState.onchainAddress, + cjitInvoice, + walletState.nodeLifecycleState + ) { + visibleTabs.associateWith { tab -> + getInvoiceForTab( + tab = tab, + bip21 = walletState.bip21, + bolt11 = walletState.bolt11, + cjitInvoice = cjitInvoice, + isNodeRunning = walletState.nodeLifecycleState.isRunning(), + onchainAddress = walletState.onchainAddress + ) + } + } + + // LazyRow state with snap behavior + val scope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + + val snapBehavior = rememberSnapFlingBehavior( + lazyListState = lazyListState, + snapPosition = SnapPosition.Center + ) + + // Calculate current tab based on scroll position for smooth indicator and color updates + var selectedTab by remember { + mutableStateOf(initialTab ?: ReceiveTab.SAVINGS) + } + + LaunchedEffect(lazyListState, visibleTabs.size) { + snapshotFlow { lazyListState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { index -> + if (index < visibleTabs.size && index > -1) { + val tab = visibleTabs[index] + selectedTab = tab + } + } + } + + // Auto-switch to AUTO tab when it becomes available for the first time + LaunchedEffect(hasUsableChannels) { + if (hasUsableChannels && visibleTabs.contains(ReceiveTab.AUTO)) { + val autoIndex = visibleTabs.indexOf(ReceiveTab.AUTO) + if (autoIndex != -1) { + lazyListState.animateScrollToItem(autoIndex) + selectedTab = ReceiveTab.AUTO + } } - mutableIntStateOf(resId) } - val onchainAddress = walletState.onchainAddress - val uri = cjitInvoice.value ?: walletState.bip21 + val showingCjitOnboarding = remember(walletState, cjitInvoice, hasUsableChannels) { + !hasUsableChannels && + walletState.nodeLifecycleState.isRunning() && + cjitInvoice.isNullOrEmpty() + } Column( modifier = modifier @@ -101,145 +180,148 @@ fun ReceiveQrScreen( .keepScreenOn() ) { SheetTopBar(stringResource(R.string.wallet__receive_bitcoin)) - Column( - modifier = Modifier.padding(horizontal = 16.dp) - ) { + Column { + Spacer(Modifier.height(16.dp)) + + // Tab row + CustomTabRowWithSpacing( + tabs = visibleTabs, + currentTabIndex = visibleTabs.indexOf(selectedTab), + selectedColor = when (selectedTab) { + SAVINGS -> Colors.Brand + AUTO -> Colors.White + SPENDING -> Colors.Purple + }, + onTabChange = { tab -> + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + val newIndex = visibleTabs.indexOf(tab) + selectedTab = tab + scope.launch { + lazyListState.animateScrollToItem(newIndex) + } + }, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(Modifier.height(24.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(1f) + + // Content area (QR or Details) with LazyRow + LazyRow( + state = lazyListState, + flingBehavior = snapBehavior, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + userScrollEnabled = true, + modifier = Modifier + .weight(1f) + .fillMaxWidth() ) { - val pagerState = rememberPagerState(initialPage = 0) { 2 } - HorizontalPager( - state = pagerState, - pageSpacing = 20.dp, - verticalAlignment = Alignment.Top, - modifier = Modifier - .weight(1f) - .testTag("ReceiveSlider") - ) { - when (it) { - 0 -> ReceiveQrSlide( - uri = uri, - qrLogoPainter = painterResource(qrLogoImageRes), - modifier = Modifier.fillMaxWidth(), - onClickEditInvoice = onClickEditInvoice - ) + itemsIndexed( + items = visibleTabs, + key = { _, tab -> tab.name } + ) { _, tab -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillParentMaxWidth() + .fillParentMaxHeight() + ) { + when { + showingCjitOnboarding && tab == ReceiveTab.SPENDING -> { + CjitOnBoardingView( + modifier = Modifier.weight(1f) + ) + } - 1 -> CopyValuesSlide( - onchainAddress = onchainAddress, - bolt11 = walletState.bolt11, - cjitInvoice = cjitInvoice.value, - receiveOnSpendingBalance = walletState.receiveOnSpendingBalance - ) + showDetails -> { + ReceiveDetailsView( + tab = tab, + walletState = walletState, + cjitInvoice = cjitInvoice, + modifier = Modifier.weight(1f) + ) + } + + else -> { + val invoice = invoicesByTab[tab].orEmpty() + + ReceiveQrView( + uri = invoice, + qrLogoPainter = painterResource(getQrLogoResource(tab)), + onClickEditInvoice = if (cjitInvoice.isNullOrEmpty()) { + onClickEditInvoice + } else { + onClickReceiveCjit + }, + tab = tab, + modifier = Modifier.fillMaxWidth() + ) + } + } } } - @Suppress("DEPRECATION") - HorizontalPagerIndicator( - pagerState = pagerState, - pageCount = pagerState.pageCount, - indicatorWidth = 8.dp, - spacing = 8.dp, - activeColor = Colors.White, - inactiveColor = Colors.White32, - modifier = Modifier - .align(Alignment.CenterHorizontally) - ) - } - Spacer(modifier = Modifier.height(24.dp)) - AnimatedVisibility(walletState.nodeLifecycleState.isRunning() && walletState.channels.isEmpty()) { - ReceiveLightningFunds( - cjitInvoice = cjitInvoice, - cjitActive = cjitActive, - onCjitToggle = onCjitToggle, - ) } - AnimatedVisibility(walletState.nodeLifecycleState.isRunning() && walletState.channels.isNotEmpty()) { - Column { - AnimatedVisibility(!walletState.receiveOnSpendingBalance) { - Headline( - text = stringResource( - R.string.wallet__receive_text_lnfunds - ).withAccent(accentColor = Colors.Purple) - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - BodyM(text = stringResource(R.string.wallet__receive_spending)) - Spacer(modifier = Modifier.weight(1f)) - AnimatedVisibility(!walletState.receiveOnSpendingBalance) { + + Spacer(Modifier.height(24.dp)) + + AnimatedVisibility(visible = walletState.nodeLifecycleState.isRunning()) { + val showCjitButton = showingCjitOnboarding && selectedTab == ReceiveTab.SPENDING + PrimaryButton( + text = stringResource( + when { + showCjitButton -> R.string.wallet__receive__cjit + showDetails -> R.string.wallet__receive_show_qr + else -> R.string.wallet__receive_show_details + } + ), + icon = { + if (showCjitButton) { Icon( - painter = painterResource(R.drawable.empty_state_arrow_horizontal), - contentDescription = null, - tint = Colors.White64, - modifier = Modifier - .rotate(17.33f) - .padding(start = 7.65.dp, end = 13.19.dp) + painter = painterResource(R.drawable.ic_lightning_alt), + tint = Colors.Purple, + contentDescription = null + ) } - Switch( - checked = walletState.receiveOnSpendingBalance, - onCheckedChange = { onClickReceiveOnSpending() }, - colors = AppSwitchDefaults.colorsPurple, - modifier = Modifier.testTag("ReceiveInstantlySwitch") - ) - } - } - } - AnimatedVisibility(walletState.nodeLifecycleState.isStarting()) { - BodyM(text = stringResource(R.string.wallet__receive_ldk_init)) - } - Spacer(modifier = Modifier.height(24.dp)) - } - } -} - -@Composable -private fun ReceiveLightningFunds( - cjitInvoice: MutableState, - cjitActive: MutableState, - onCjitToggle: (Boolean) -> Unit, -) { - Column { - AnimatedVisibility(!cjitActive.value && cjitInvoice.value == null) { - Headline( - text = stringResource(R.string.wallet__receive_text_lnfunds).withAccent(accentColor = Colors.Purple) - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - BodyM(text = stringResource(R.string.wallet__receive_spending)) - Spacer(modifier = Modifier.weight(1f)) - AnimatedVisibility(!cjitActive.value && cjitInvoice.value == null) { - Icon( - painter = painterResource(R.drawable.empty_state_arrow_horizontal), - contentDescription = null, - tint = Colors.White64, + }, + onClick = { + if (showCjitButton) { + onClickReceiveCjit() + showDetails = false + } else { + showDetails = !showDetails + } + }, + fullWidth = true, modifier = Modifier - .rotate(17.33f) - .padding(start = 7.65.dp, end = 13.19.dp) + .padding(horizontal = 16.dp) + .testTag( + if (showDetails) { + "QRCode" + } else { + "ShowDetails" + } + ) ) } - Switch( - checked = cjitActive.value, - onCheckedChange = onCjitToggle, - colors = AppSwitchDefaults.colorsPurple, - ) + + Spacer(Modifier.height(16.dp)) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ReceiveQrSlide( +private fun ReceiveQrView( uri: String, qrLogoPainter: Painter, - modifier: Modifier, onClickEditInvoice: () -> Unit, + tab: ReceiveTab, + modifier: Modifier = Modifier, ) { val context = LocalContext.current - val qrButtonTooltipState = rememberTooltipState() val coroutineScope = rememberCoroutineScope() - var qrBitmap by remember { mutableStateOf(null) } Column( @@ -270,7 +352,7 @@ private fun ReceiveQrSlide( Icon( painter = painterResource(R.drawable.ic_pencil_simple), contentDescription = null, - tint = Colors.Brand, + tint = tab.accentColor, modifier = Modifier.size(18.dp) ) }, @@ -293,7 +375,7 @@ private fun ReceiveQrSlide( Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = null, - tint = Colors.Brand, + tint = tab.accentColor, modifier = Modifier.size(18.dp) ) }, @@ -314,7 +396,7 @@ private fun ReceiveQrSlide( Icon( painter = painterResource(R.drawable.ic_share), contentDescription = null, - tint = Colors.Brand, + tint = tab.accentColor, modifier = Modifier.size(18.dp) ) }, @@ -325,39 +407,111 @@ private fun ReceiveQrSlide( } @Composable -private fun CopyValuesSlide( - onchainAddress: String, - bolt11: String, +fun CjitOnBoardingView(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .clip(AppShapes.small) + .background(color = Colors.Black) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Display(stringResource(R.string.wallet__receive_onboarding_title).withAccent(accentColor = Colors.Purple)) + VerticalSpacer(8.dp) + BodyM( + stringResource(R.string.wallet__receive_onboarding_description), + color = Colors.White64, + modifier = Modifier.fillMaxWidth() + ) + VerticalSpacer(32.dp) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_lightning_alt), + tint = Colors.Purple, + contentDescription = null, + modifier = Modifier + .size(64.dp) + .align(Alignment.TopCenter) + ) + Icon( + painter = painterResource(R.drawable.arrow), + tint = Colors.Purple, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 32.dp) + .fillMaxHeight() + ) + } + } +} + +@Composable +private fun ReceiveDetailsView( + tab: ReceiveTab, + walletState: MainUiState, cjitInvoice: String?, - receiveOnSpendingBalance: Boolean, + modifier: Modifier = Modifier, ) { Card( - colors = CardDefaults.cardColors(containerColor = Colors.White10), + colors = CardDefaults.cardColors(containerColor = Colors.Black), shape = AppShapes.small, + modifier = modifier ) { Column { - if (onchainAddress.isNotEmpty() && cjitInvoice == null) { - CopyAddressCard( - title = stringResource(R.string.wallet__receive_bitcoin_invoice), - address = onchainAddress, - type = CopyAddressType.ONCHAIN, - testTag = "ReceiveOnchainAddress", - ) - } - if (bolt11.isNotEmpty() && receiveOnSpendingBalance) { - CopyAddressCard( - title = stringResource(R.string.wallet__receive_lightning_invoice), - address = bolt11, - type = CopyAddressType.LIGHTNING, - testTag = "ReceiveLightningAddress", - ) - } else if (cjitInvoice != null) { - CopyAddressCard( - title = stringResource(R.string.wallet__receive_lightning_invoice), - address = cjitInvoice, - type = CopyAddressType.LIGHTNING, - testTag = "ReceiveLightningAddress", - ) + when (tab) { + ReceiveTab.SAVINGS -> { + if (walletState.onchainAddress.isNotEmpty()) { + CopyAddressCard( + title = stringResource(R.string.wallet__receive_bitcoin_invoice), + address = removeLightningFromBip21( + bip21 = walletState.bip21, + fallbackAddress = walletState.onchainAddress + ), + body = walletState.onchainAddress, + type = CopyAddressType.ONCHAIN, + testTag = "ReceiveOnchainAddress", + ) + } + } + + ReceiveTab.AUTO -> { + // Show both onchain AND lightning if available + if (walletState.onchainAddress.isNotEmpty()) { + CopyAddressCard( + title = stringResource(R.string.wallet__receive_bitcoin_invoice), + address = removeLightningFromBip21( + bip21 = walletState.bip21, + fallbackAddress = walletState.onchainAddress + ), + body = walletState.onchainAddress, + type = CopyAddressType.ONCHAIN, + testTag = "ReceiveOnchainAddress", + ) + } + if (cjitInvoice != null || walletState.bolt11.isNotEmpty()) { + CopyAddressCard( + title = stringResource(R.string.wallet__receive_lightning_invoice), + address = cjitInvoice ?: walletState.bolt11, + type = CopyAddressType.LIGHTNING, + testTag = "ReceiveLightningAddress", + ) + } + } + + ReceiveTab.SPENDING -> { + if (cjitInvoice != null || walletState.bolt11.isNotEmpty()) { + CopyAddressCard( + title = stringResource(R.string.wallet__receive_lightning_invoice), + address = cjitInvoice ?: walletState.bolt11, + type = CopyAddressType.LIGHTNING, + testTag = "ReceiveLightningAddress", + ) + } + } } } } @@ -371,6 +525,7 @@ private fun CopyAddressCard( title: String, address: String, type: CopyAddressType, + body: String? = null, testTag: String? = null, ) { val context = LocalContext.current @@ -393,7 +548,7 @@ private fun CopyAddressCard( } Spacer(modifier = Modifier.height(16.dp)) BodyS( - text = address.truncate(32).uppercase(), + text = (body ?: address).truncate(32).uppercase(), modifier = testTag?.let { Modifier.testTag(it) } ?: Modifier ) Spacer(modifier = Modifier.height(16.dp)) @@ -442,21 +597,152 @@ private fun CopyAddressCard( } } -@Preview(showSystemUi = true) +@Suppress("SpellCheckingInspection") +@Preview(showSystemUi = true, name = "Savings Mode") +@Composable +private fun PreviewSavingsMode() { + AppThemeSurface { + BottomSheetPreview { + ReceiveQrScreen( + cjitInvoice = null, + walletState = MainUiState( + nodeLifecycleState = NodeLifecycleState.Running, + onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", + channels = emptyList() + ), + onClickEditInvoice = {}, + modifier = Modifier.sheetHeight(), + initialTab = ReceiveTab.SAVINGS, + onClickReceiveCjit = {}, + ) + } + } +} + +@Suppress("SpellCheckingInspection") +@Preview(showSystemUi = true, name = "Auto Mode") +@Composable +private fun PreviewAutoMode() { + // Mock channel for preview (AUTO tab requires non-empty channels list) + val mockChannel = ChannelDetails( + channelId = "0".repeat(64), + counterpartyNodeId = "0".repeat(66), + fundingTxo = null, + shortChannelId = null, + outboundScidAlias = null, + inboundScidAlias = null, + channelValueSats = 1000000uL, + unspendablePunishmentReserve = null, + userChannelId = "0".repeat(32), + feerateSatPer1000Weight = 1000u, + outboundCapacityMsat = 500000000uL, + inboundCapacityMsat = 500000000uL, + confirmationsRequired = null, + confirmations = null, + isOutbound = true, + isChannelReady = true, + isUsable = true, + isAnnounced = false, + cltvExpiryDelta = null, + counterpartyUnspendablePunishmentReserve = 0uL, + counterpartyOutboundHtlcMinimumMsat = null, + counterpartyOutboundHtlcMaximumMsat = null, + counterpartyForwardingInfoFeeBaseMsat = null, + counterpartyForwardingInfoFeeProportionalMillionths = null, + counterpartyForwardingInfoCltvExpiryDelta = null, + nextOutboundHtlcLimitMsat = 0uL, + nextOutboundHtlcMinimumMsat = 0uL, + forceCloseSpendDelay = null, + inboundHtlcMinimumMsat = 0uL, + inboundHtlcMaximumMsat = null, + config = org.lightningdevkit.ldknode.ChannelConfig( + forwardingFeeProportionalMillionths = 0u, + forwardingFeeBaseMsat = 0u, + cltvExpiryDelta = 0u, + maxDustHtlcExposure = org.lightningdevkit.ldknode.MaxDustHtlcExposure.FeeRateMultiplier(0uL), + forceCloseAvoidanceMaxFeeSatoshis = 0uL, + acceptUnderpayingHtlcs = false + ) + ) + + AppThemeSurface { + BottomSheetPreview { + ReceiveQrScreen( + cjitInvoice = null, + walletState = MainUiState( + nodeLifecycleState = NodeLifecycleState.Running, + channels = listOf(mockChannel), + onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", + bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfvdjjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq", + bip21 = "bitcoin:bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l?lightning=lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79..." + ), + onClickEditInvoice = {}, + modifier = Modifier.sheetHeight(), + initialTab = ReceiveTab.AUTO, + onClickReceiveCjit = {}, + ) + } + } +} + +@Suppress("SpellCheckingInspection") +@Preview(showSystemUi = true, name = "Spending Mode") @Composable -private fun Preview() { +private fun PreviewSpendingMode() { + val mockChannel = ChannelDetails( + channelId = "0".repeat(64), + counterpartyNodeId = "0".repeat(66), + fundingTxo = null, + shortChannelId = null, + outboundScidAlias = null, + inboundScidAlias = null, + channelValueSats = 1000000uL, + unspendablePunishmentReserve = null, + userChannelId = "0".repeat(32), + feerateSatPer1000Weight = 1000u, + outboundCapacityMsat = 500000000uL, + inboundCapacityMsat = 500000000uL, + confirmationsRequired = null, + confirmations = null, + isOutbound = true, + isChannelReady = true, + isUsable = true, + isAnnounced = false, + cltvExpiryDelta = null, + counterpartyUnspendablePunishmentReserve = 0uL, + counterpartyOutboundHtlcMinimumMsat = null, + counterpartyOutboundHtlcMaximumMsat = null, + counterpartyForwardingInfoFeeBaseMsat = null, + counterpartyForwardingInfoFeeProportionalMillionths = null, + counterpartyForwardingInfoCltvExpiryDelta = null, + nextOutboundHtlcLimitMsat = 0uL, + nextOutboundHtlcMinimumMsat = 0uL, + forceCloseSpendDelay = null, + inboundHtlcMinimumMsat = 0uL, + inboundHtlcMaximumMsat = null, + config = org.lightningdevkit.ldknode.ChannelConfig( + forwardingFeeProportionalMillionths = 0u, + forwardingFeeBaseMsat = 0u, + cltvExpiryDelta = 0u, + maxDustHtlcExposure = org.lightningdevkit.ldknode.MaxDustHtlcExposure.FeeRateMultiplier(0uL), + forceCloseAvoidanceMaxFeeSatoshis = 0uL, + acceptUnderpayingHtlcs = false + ) + ) + AppThemeSurface { BottomSheetPreview { ReceiveQrScreen( - cjitInvoice = remember { mutableStateOf(null) }, - cjitActive = remember { mutableStateOf(false) }, + cjitInvoice = null, walletState = MainUiState( nodeLifecycleState = NodeLifecycleState.Running, + channels = listOf(mockChannel), + bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfvdjjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq" ), - onCjitToggle = {}, onClickEditInvoice = {}, - onClickReceiveOnSpending = {}, modifier = Modifier.sheetHeight(), + initialTab = ReceiveTab.SPENDING, + onClickReceiveCjit = {}, ) } } @@ -468,14 +754,12 @@ private fun PreviewNodeNotReady() { AppThemeSurface { BottomSheetPreview { ReceiveQrScreen( - cjitInvoice = remember { mutableStateOf(null) }, - cjitActive = remember { mutableStateOf(false) }, + cjitInvoice = null, walletState = MainUiState( nodeLifecycleState = NodeLifecycleState.Starting, ), - onCjitToggle = {}, + onClickReceiveCjit = {}, onClickEditInvoice = {}, - onClickReceiveOnSpending = {}, modifier = Modifier.sheetHeight(), ) } @@ -488,35 +772,37 @@ private fun PreviewSmall() { AppThemeSurface { BottomSheetPreview { ReceiveQrScreen( - cjitInvoice = remember { mutableStateOf(null) }, - cjitActive = remember { mutableStateOf(false) }, + cjitInvoice = null, walletState = MainUiState( nodeLifecycleState = NodeLifecycleState.Running, ), - onCjitToggle = {}, onClickEditInvoice = {}, - onClickReceiveOnSpending = {}, modifier = Modifier.sheetHeight(), + onClickReceiveCjit = {}, ) } } } @Suppress("SpellCheckingInspection") -@Preview(showBackground = true) +@Preview(showSystemUi = true, name = "Auto Mode") @Composable -private fun PreviewSlide2() { +private fun PreviewDetailsMode() { AppThemeSurface { Column( modifier = Modifier .gradientBackground() + .fillMaxSize() .padding(16.dp) ) { - CopyValuesSlide( - onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", - bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79...", + ReceiveDetailsView( + tab = ReceiveTab.AUTO, + walletState = MainUiState( + onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", + bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79...", + ), cjitInvoice = null, - true + modifier = Modifier.weight(1f) ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index c2dccc46c..2027c58de 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -75,28 +75,17 @@ fun ReceiveSheet( } ReceiveQrScreen( - cjitInvoice = cjitInvoice, - cjitActive = showCreateCjit, + cjitInvoice = cjitInvoice.value, walletState = walletState, - onCjitToggle = { isOn -> - when { - isOn && lightningState.shouldBlockLightningReceive -> { - navController.navigate(ReceiveRoute.GeoBlock) - } - - !isOn -> { - showCreateCjit.value = false - cjitInvoice.value = null - } - - isOn && cjitInvoice.value == null -> { - showCreateCjit.value = true - navController.navigate(ReceiveRoute.Amount) - } + onClickReceiveCjit = { + if (lightningState.isGeoBlocked) { + navController.navigate(ReceiveRoute.GeoBlock) + } else { + showCreateCjit.value = true + navController.navigate(ReceiveRoute.Amount) } }, onClickEditInvoice = { navController.navigate(ReceiveRoute.EditInvoice) }, - onClickReceiveOnSpending = { wallet.toggleReceiveOnSpending() } ) } composableWithDefaultTransitions { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt new file mode 100644 index 000000000..1d1c8611c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt @@ -0,0 +1,29 @@ +package to.bitkit.ui.screens.wallets.receive + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import to.bitkit.R +import to.bitkit.ui.screens.wallets.activity.components.TabItem +import to.bitkit.ui.theme.Colors + +enum class ReceiveTab : TabItem { + SAVINGS, + AUTO, + SPENDING; + + override val uiText: String + @Composable + get() = when (this) { + SAVINGS -> stringResource(R.string.wallet__receive_tab_savings) + AUTO -> stringResource(R.string.wallet__receive_tab_auto) + SPENDING -> stringResource(R.string.wallet__receive_tab_spending) + } + + val accentColor: Color + get() = when (this) { + SAVINGS -> Colors.Brand + AUTO -> Colors.Brand + SPENDING -> Colors.Purple + } +} diff --git a/app/src/main/java/to/bitkit/ui/shared/animations/TabTransitionAnimations.kt b/app/src/main/java/to/bitkit/ui/shared/animations/TabTransitionAnimations.kt new file mode 100644 index 000000000..3e2cc32a6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/shared/animations/TabTransitionAnimations.kt @@ -0,0 +1,70 @@ +package to.bitkit.ui.shared.animations + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.ui.unit.IntOffset + +/** + * Animation specifications for tab transitions with iOS-like smooth feel. + * + * - Tween animation: 450ms duration with FastOutSlowInEasing + * - Direction-aware horizontal sliding + * - Smooth, natural feel matching iOS PageTabViewStyle + */ +object TabTransitionAnimations { + + /** + * Tween animation for smooth tab content transitions. + * - Duration: 300ms (matches iOS native tab transitions) + * - Easing: FastOutSlowInEasing (iOS-like deceleration curve) + */ + private val tabTweenSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) + + /** + * Tween animation for IntOffset (horizontal sliding). + */ + private val tabTweenSpecIntOffset = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) + + /** + * Direction-aware tab content transition. + * + * @param isForward true if moving to next tab (swipe left), false if previous (swipe right) + */ + fun tabContentTransition(isForward: Boolean): ContentTransform { + val slideInOffset = if (isForward) { + { fullWidth: Int -> fullWidth } // Slide in from right + } else { + { fullWidth: Int -> -fullWidth } // Slide in from left + } + + val slideOutOffset = if (isForward) { + { fullWidth: Int -> -fullWidth / 5 } // Slide out to left (20% parallax) + } else { + { fullWidth: Int -> fullWidth / 5 } // Slide out to right (20% parallax) + } + + return slideInHorizontally( + initialOffsetX = slideInOffset, + animationSpec = tabTweenSpecIntOffset + ) + fadeIn( + animationSpec = tabTweenSpec + ) togetherWith slideOutHorizontally( + targetOffsetX = slideOutOffset, + animationSpec = tabTweenSpecIntOffset + ) + fadeOut( + animationSpec = tabTweenSpec + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/shared/modifiers/SwipeToChangeTabModifier.kt b/app/src/main/java/to/bitkit/ui/shared/modifiers/SwipeToChangeTabModifier.kt new file mode 100644 index 000000000..82c6bd1c8 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/shared/modifiers/SwipeToChangeTabModifier.kt @@ -0,0 +1,82 @@ +package to.bitkit.ui.shared.modifiers + +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import kotlin.math.abs + +/** + * Enables tab navigation via horizontal swipe gestures. + * + * Detects horizontal swipe using both velocity and drag distance to provide + * iOS-like swipe sensitivity. Navigates to adjacent tabs when either: + * - Velocity exceeds threshold (for quick flicks) + * - Drag distance exceeds threshold (for slower, deliberate swipes) + * + * @param currentTabIndex The currently selected tab index (0-based) + * @param tabCount Total number of tabs available for navigation + * @param onTabChange Callback invoked when user swipes to change tabs, receives the new tab index + * @param velocityThreshold Velocity threshold in px/s (default: 600f) + * @param distanceThreshold Distance threshold in dp (default: 50.dp) + */ +fun Modifier.swipeToChangeTab( + currentTabIndex: Int, + tabCount: Int, + onTabChange: (Int) -> Unit, + velocityThreshold: Float = DEFAULT_VELOCITY_THRESHOLD, + distanceThreshold: Float = DEFAULT_DISTANCE_THRESHOLD_DP, +): Modifier = composed { + val velocityTracker = remember { VelocityTracker() } + var totalDragDistance by remember { mutableFloatStateOf(0f) } + val distanceThresholdPx = with(LocalDensity.current) { distanceThreshold.dp.toPx() } + + pointerInput(currentTabIndex) { + detectHorizontalDragGestures( + onHorizontalDrag = { change, dragAmount -> + velocityTracker.addPosition(change.uptimeMillis, change.position) + totalDragDistance += dragAmount + }, + onDragEnd = { + val velocity = velocityTracker.calculateVelocity().x + val dragDistance = totalDragDistance + + // Check if either velocity OR distance threshold is met + val shouldNavigate = abs(velocity) >= velocityThreshold || + abs(dragDistance) >= distanceThresholdPx + + if (shouldNavigate) { + when { + // Swipe right (previous tab) - positive velocity/drag + (velocity > 0 || dragDistance > 0) && currentTabIndex > 0 -> + onTabChange(currentTabIndex - 1) + + // Swipe left (next tab) - negative velocity/drag + (velocity < 0 || dragDistance < 0) && currentTabIndex < tabCount - 1 -> + onTabChange(currentTabIndex + 1) + } + } + + velocityTracker.resetTracking() + totalDragDistance = 0f + }, + onDragCancel = { + velocityTracker.resetTracking() + totalDragDistance = 0f + }, + ) + } +} + +// Reduced from 1500 to 600 for better iOS-like sensitivity +private const val DEFAULT_VELOCITY_THRESHOLD = 600f + +// Added distance threshold: 50dp drag triggers navigation +private const val DEFAULT_DISTANCE_THRESHOLD_DP = 50f diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c9546614e..1732edd4b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1448,6 +1448,16 @@ class AppViewModel @Inject constructor( toast(type = Toast.ToastType.ERROR, title = "Error", description = error.message ?: "Unknown error") } + fun toast(toast: Toast) { + toast( + type = toast.type, + title = toast.title, + description = toast.description, + autoHide = toast.autoHide, + visibilityTime = toast.visibilityTime + ) + } + fun hideToast() { currentToast = null } diff --git a/app/src/main/res/drawable/arrow.xml b/app/src/main/res/drawable/arrow.xml new file mode 100644 index 000000000..9d3ab6b58 --- /dev/null +++ b/app/src/main/res/drawable/arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/qr_placeholder.xml b/app/src/main/res/drawable/qr_placeholder.xml new file mode 100644 index 000000000..4b2514715 --- /dev/null +++ b/app/src/main/res/drawable/qr_placeholder.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 171d3437e..a3a42c6a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -963,7 +963,12 @@ Bitcoin invoice Lightning invoice Optional note to payer + Receive Lightning funds Show QR Code + Show Details + Savings + Auto + Spending Want to receive <accent>Lightning</accent> funds? Receive on Spending Balance To set up your spending balance, a <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted. @@ -978,6 +983,11 @@ Failed to send funds to your spending account. You will receive Spending Balance Initializing... + Receive on <accent>spending balance</accent> + Enjoy instant and cheap\ntransactions with friends, family,\nand merchants. + Generating QR ... + Instant Payments Unavailable + Bitkit does not provide Lightning services in your country, but you can still connect to other nodes. MINIMUM Activity Show All Activity diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt new file mode 100644 index 000000000..9813d2a7c --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt @@ -0,0 +1,217 @@ +package to.bitkit.ui.screens.wallets.receive + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReceiveInvoiceUtilsTest { + + private val testAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + private val testBolt11 = "lnbc1500n1pn2s39xpp5wyxw0e9fvvf..." + private val testCjitInvoice = "lnbc2000n1pn2s39xpp5zyxw0e9fvvf..." + + @Test + fun `getInvoiceForTab SAVINGS returns BIP21 without lightning parameter`() { + val bip21WithAmount = "bitcoin:$testAddress?amount=0.001&message=Test&lightning=$testBolt11" + + val result = getInvoiceForTab( + tab = ReceiveTab.SAVINGS, + bip21 = bip21WithAmount, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = testAddress + ) + + assertEquals("bitcoin:$testAddress?amount=0.001&message=Test", result) + } + + @Test + fun `getInvoiceForTab SAVINGS preserves amount when lightning is last parameter`() { + val bip21 = "bitcoin:$testAddress?amount=0.00050000&lightning=$testBolt11" + + val result = getInvoiceForTab( + tab = ReceiveTab.SAVINGS, + bip21 = bip21, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = testAddress + ) + + assertEquals("bitcoin:$testAddress?amount=0.00050000", result) + } + + @Test + fun `getInvoiceForTab SAVINGS handles BIP21 without lightning parameter`() { + val bip21WithoutLightning = "bitcoin:$testAddress?amount=0.002&message=Test" + + val result = getInvoiceForTab( + tab = ReceiveTab.SAVINGS, + bip21 = bip21WithoutLightning, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = testAddress + ) + + assertEquals("bitcoin:$testAddress?amount=0.002&message=Test", result) + } + + @Test + fun `getInvoiceForTab SAVINGS returns fallback address when BIP21 is empty`() { + val result = getInvoiceForTab( + tab = ReceiveTab.SAVINGS, + bip21 = "", + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = testAddress + ) + + assertEquals(testAddress, result) + } + + @Test + fun `getInvoiceForTab SAVINGS returns fallback when BIP21 only has lightning`() { + val bip21OnlyLightning = "bitcoin:$testAddress?lightning=$testBolt11" + + val result = getInvoiceForTab( + tab = ReceiveTab.SAVINGS, + bip21 = bip21OnlyLightning, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = testAddress + ) + + assertEquals("bitcoin:$testAddress", result) + } + + @Test + fun `getInvoiceForTab AUTO returns full BIP21 when node running and has lightning`() { + val bip21 = "bitcoin:$testAddress?amount=0.001&lightning=$testBolt11" + + val result = getInvoiceForTab( + tab = ReceiveTab.AUTO, + bip21 = bip21, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = testAddress + ) + + assertEquals(bip21, result) + } + + @Test + fun `getInvoiceForTab AUTO returns empty when has lightning but node not running`() { + val bip21 = "bitcoin:$testAddress?amount=0.001&lightning=$testBolt11" + + val result = getInvoiceForTab( + tab = ReceiveTab.AUTO, + bip21 = bip21, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = false, + onchainAddress = testAddress + ) + + assertEquals("", result) + } + + @Test + fun `getInvoiceForTab AUTO returns empty when BIP21 has no lightning even if node running`() { + val bip21WithoutLightning = "bitcoin:$testAddress?amount=0.001&message=Test" + + val result = getInvoiceForTab( + tab = ReceiveTab.AUTO, + bip21 = bip21WithoutLightning, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = testAddress + ) + + assertEquals("", result) + } + + @Test + fun `getInvoiceForTab AUTO returns empty when no lightning and node not running`() { + val bip21WithoutLightning = "bitcoin:$testAddress?amount=0.001&message=Test" + + val result = getInvoiceForTab( + tab = ReceiveTab.AUTO, + bip21 = bip21WithoutLightning, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = false, + onchainAddress = testAddress + ) + + assertEquals("", result) + } + + @Test + fun `getInvoiceForTab AUTO detects lightning when it is the first parameter`() { + val bip21LightningFirst = "bitcoin:$testAddress?lightning=$testBolt11&amount=0.001" + + val result = getInvoiceForTab( + tab = ReceiveTab.AUTO, + bip21 = bip21LightningFirst, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = testAddress + ) + + assertEquals(bip21LightningFirst, result) + } + + @Test + fun `getInvoiceForTab SPENDING returns CJIT invoice when available and node running`() { + val bip21 = "bitcoin:$testAddress?lightning=$testBolt11" + + val result = getInvoiceForTab( + tab = ReceiveTab.SPENDING, + bip21 = bip21, + bolt11 = testBolt11, + cjitInvoice = testCjitInvoice, + isNodeRunning = true, + onchainAddress = testAddress + ) + + assertEquals(testCjitInvoice, result) + } + + @Test + fun `getInvoiceForTab SPENDING returns bolt11 when CJIT unavailable`() { + val bip21 = "bitcoin:$testAddress?lightning=$testBolt11" + + val result = getInvoiceForTab( + tab = ReceiveTab.SPENDING, + bip21 = bip21, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = testAddress + ) + + assertEquals(testBolt11, result) + } + + @Test + fun `getInvoiceForTab SPENDING returns bolt11 when node not running even with CJIT`() { + val bip21 = "bitcoin:$testAddress?lightning=$testBolt11" + + val result = getInvoiceForTab( + tab = ReceiveTab.SPENDING, + bip21 = bip21, + bolt11 = testBolt11, + cjitInvoice = testCjitInvoice, + isNodeRunning = false, + onchainAddress = testAddress + ) + + assertEquals(testBolt11, result) + } +}