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 81b081f470..16bb69e7da 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 @@ -316,7 +316,7 @@ fun ReceiveQrScreen( Column( modifier = modifier.fillMaxSize().background( Colors.Gray7 - ).navigationBarsPadding().padding(horizontal = 16.dp) + ).navigationBarsPadding() ) { UsdtDepositReceiveScreen( viewModel = hiltViewModel(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt index 92f01d4671..bc7f8df7eb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt @@ -1,6 +1,10 @@ package to.bitkit.ui.screens.wallets.usdt import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.bitkitcore.UsdtDepositAddress import com.synonym.bitkitcore.UsdtDepositDetail import com.synonym.bitkitcore.UsdtDepositNetwork import com.synonym.bitkitcore.usdtFormatAmount @@ -58,6 +63,7 @@ import to.bitkit.ui.screens.wallets.receive.ReceiveTab import to.bitkit.ui.shared.modifiers.swipeToChangeTab import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.Transitions import java.text.DecimalFormatSymbols import java.text.NumberFormat import java.util.Locale @@ -102,93 +108,111 @@ internal fun ColumnScope.UsdtDepositReceiveScreen( LaunchedEffect(state.address) { if (state.address != null) page = ReceivePage.FEES } UsdtRefreshEffect(viewModel, receiving = true, onReceived = { !state.busy }) BackHandler(onBack = back) - SheetTopBar( - titleText = stringResource( - when (page) { - ReceivePage.ADDRESS -> R.string.usdt__receive_title - ReceivePage.NETWORKS -> R.string.usdt__receive_network_title - ReceivePage.AMOUNT -> R.string.usdt__receive_amount_title - ReceivePage.FEES -> R.string.usdt__receive_fees_title - } - ), - onBack = back.takeIf { page != ReceivePage.ADDRESS }, - action = if (page == ReceivePage.ADDRESS && onContacts != null) { - { - IconButton(onClick = onContacts) { - Icon( - painterResource(R.drawable.ic_users), - stringResource(R.string.wallet__payment_request_choose_recipient) - ) - } + AnimatedContent( + targetState = ShownPage(page, state, if (page == ReceivePage.AMOUNT) draftAmount else amount), + contentKey = { it.page }, + transitionSpec = { + if (targetState.page > initialState.page) { + Transitions.defaultEnter togetherWith Transitions.defaultExit + } else { + ContentTransform(Transitions.defaultPopEnter, Transitions.defaultPopExit, targetContentZIndex = -1f) } - } else { - null }, - ) - VerticalSpacer( - if (page == ReceivePage.NETWORKS || page == ReceivePage.AMOUNT) 8.dp else 16.dp - ) - if (page == ReceivePage.ADDRESS) { - CustomTabRowWithSpacing( - tabs = tabs, - currentTabIndex = tabs.indexOf(ReceiveTab.USDT), - labelOverrides = tabLabels, - onTabChange = onSelectTab - ) - VerticalSpacer(16.dp) - } - walletUi.error?.let { BodyS(stringResource(it), color = Colors.Brand) } - val tabIndex = tabs.indexOf(ReceiveTab.USDT) - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .then( - if (page == ReceivePage.ADDRESS) { - Modifier.swipeToChangeTab(tabIndex, tabs.size, { onSelectTab(tabs[it]) }, wrapAround = true) + label = "UsdtReceivePage", + modifier = Modifier.weight(1f) + ) { shown -> + val target = shown.page + val isCurrent = { target == page } + Column(modifier = Modifier.fillMaxSize().background(Colors.Gray7).padding(horizontal = 16.dp)) { + SheetTopBar( + titleText = stringResource( + when (target) { + ReceivePage.ADDRESS -> R.string.usdt__receive_title + ReceivePage.NETWORKS -> R.string.usdt__receive_network_title + ReceivePage.AMOUNT -> R.string.usdt__receive_amount_title + ReceivePage.FEES -> R.string.usdt__receive_fees_title + } + ), + onBack = whenCurrent(isCurrent, back).takeIf { target != ReceivePage.ADDRESS }, + action = if (target == ReceivePage.ADDRESS && onContacts != null) { + { + IconButton(onClick = onContacts) { + Icon( + painterResource(R.drawable.ic_users), + stringResource(R.string.wallet__payment_request_choose_recipient) + ) + } + } } else { - Modifier - } + null + }, ) - ) { - UsdtDepositReceiveContent( - page = page, - onPage = { page = it }, - wallet = wallet, - receiveError = walletUi.refreshError, - state = state, - amount = if (page == ReceivePage.AMOUNT) draftAmount else amount, - onNetwork = { - if (state.network != it) { - amount = "" - draftAmount = "" - deposits.selectNetwork(it) - } - page = if (it == null || state.address != null && state.network == it) { - ReceivePage.ADDRESS - } else { - ReceivePage.AMOUNT - } - }, - onAmount = { - if (draftAmount != it) deposits.clearError() - draftAmount = it - }, - onCreate = { - if (state.network == null) { - amount = draftAmount - page = ReceivePage.ADDRESS - } else { - deposits.prepare(draftAmount) - } - }, - onHistory = { app?.navigateToUsdtDeposits() }, - onRefresh = { - draftAmount = if (state.network == null) amount else draftAmount - deposits.selectNetwork(state.network) - page = ReceivePage.AMOUNT - }, - ) + VerticalSpacer( + if (target == ReceivePage.NETWORKS || target == ReceivePage.AMOUNT) 8.dp else 16.dp + ) + if (target == ReceivePage.ADDRESS) { + CustomTabRowWithSpacing( + tabs = tabs, + currentTabIndex = tabs.indexOf(ReceiveTab.USDT), + labelOverrides = tabLabels, + onTabChange = onSelectTab + ) + VerticalSpacer(16.dp) + } + walletUi.error?.let { BodyS(stringResource(it), color = Colors.Brand) } + val tabIndex = tabs.indexOf(ReceiveTab.USDT) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .then( + if (target == ReceivePage.ADDRESS) { + Modifier.swipeToChangeTab(tabIndex, tabs.size, { onSelectTab(tabs[it]) }, wrapAround = true) + } else { + Modifier + } + ) + ) { + UsdtDepositReceiveContent( + page = target, + onPage = whenCurrentOf(isCurrent) { page = it }, + wallet = wallet, + receiveError = walletUi.refreshError, + state = shown.state, + amount = shown.amount, + onNetwork = whenCurrentOf(isCurrent) { + if (state.network != it) { + amount = "" + draftAmount = "" + deposits.selectNetwork(it) + } + page = if (it == null || state.address != null && state.network == it) { + ReceivePage.ADDRESS + } else { + ReceivePage.AMOUNT + } + }, + onAmount = whenCurrentOf(isCurrent) { + if (draftAmount != it) deposits.clearError() + draftAmount = it + }, + onCreate = whenCurrent(isCurrent) { + if (state.network == null) { + amount = draftAmount + page = ReceivePage.ADDRESS + } else { + deposits.prepare(draftAmount) + } + }, + onHistory = { app?.navigateToUsdtDeposits() }, + onRefresh = whenCurrent(isCurrent) { + draftAmount = if (state.network == null) amount else draftAmount + deposits.selectNetwork(state.network) + page = ReceivePage.AMOUNT + }, + ) + } + } } } @@ -217,24 +241,8 @@ internal fun ColumnScope.UsdtDepositReceiveContent( onAmount, onCreate ) - ReceivePage.FEES -> state.address?.let { address -> - UsdtAmountHeader(usdtFormatAmount(address.amount), displayAmount = usdtOverviewAmount(address.amount)) - VerticalSpacer(24.dp) - BodyM(stringResource(R.string.usdt__deposit_fee_note), color = Colors.White64, letterSpacing = 0.sp) - VerticalSpacer(24.dp) - EstimateRow( - stringResource(R.string.usdt__receive_fee), - if (address.amount >= address.estimatedReceived) { - usdtOverviewAmount(address.amount - address.estimatedReceived) - } else { - "—" - } - ) - EstimateRow(stringResource(R.string.usdt__receive_estimate), usdtOverviewAmount(address.estimatedReceived)) - FillHeight() - PrimaryButton(stringResource(R.string.common__continue), onClick = { - onPage(ReceivePage.ADDRESS) - }, textStyle = UsdtDetailStyles.Body, modifier = Modifier.testTag("UsdtReceiveFeesContinue")) + ReceivePage.FEES -> { + FeeEstimate(state.address, onContinue = { onPage(ReceivePage.ADDRESS) }) } ReceivePage.ADDRESS -> { val atomic = runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, amount).atomic }.getOrNull() @@ -253,8 +261,41 @@ internal fun ColumnScope.UsdtDepositReceiveContent( } } +@Composable +private fun ColumnScope.FeeEstimate(address: UsdtDepositAddress?, onContinue: () -> Unit) { + if (address == null) return + UsdtAmountHeader(usdtFormatAmount(address.amount), displayAmount = usdtOverviewAmount(address.amount)) + VerticalSpacer(24.dp) + BodyM(stringResource(R.string.usdt__deposit_fee_note), color = Colors.White64, letterSpacing = 0.sp) + VerticalSpacer(24.dp) + EstimateRow( + stringResource(R.string.usdt__receive_fee), + if (address.amount >= address.estimatedReceived) { + usdtOverviewAmount(address.amount - address.estimatedReceived) + } else { + "—" + } + ) + EstimateRow(stringResource(R.string.usdt__receive_estimate), usdtOverviewAmount(address.estimatedReceived)) + FillHeight() + PrimaryButton( + stringResource(R.string.common__continue), + onClick = onContinue, + textStyle = UsdtDetailStyles.Body, + modifier = Modifier.testTag("UsdtReceiveFeesContinue") + ) +} + internal enum class ReceivePage { ADDRESS, NETWORKS, AMOUNT, FEES } +private fun whenCurrent(isCurrent: () -> Boolean, block: () -> Unit): () -> Unit = { if (isCurrent()) block() } + +private fun whenCurrentOf(isCurrent: () -> Boolean, block: (T) -> Unit): (T) -> Unit = { + if (isCurrent()) block(it) +} + +private data class ShownPage(val page: ReceivePage, val state: DepositUiState, val amount: String) + @Composable private fun ColumnScope.NetworkSelector( state: DepositUiState, diff --git a/app/src/main/java/to/bitkit/ui/utils/Transitions.kt b/app/src/main/java/to/bitkit/ui/utils/Transitions.kt index 9cf2bb5697..b330f788d7 100644 --- a/app/src/main/java/to/bitkit/ui/utils/Transitions.kt +++ b/app/src/main/java/to/bitkit/ui/utils/Transitions.kt @@ -33,38 +33,43 @@ object Transitions { val slideInVertically = slideInVertically(animationSpec = tween(), initialOffsetY = { it }) val slideOutVertically = slideOutVertically(animationSpec = tween(), targetOffsetY = { it }) - val defaultEnterTrans: (AnimatedContentTransitionScope.() -> EnterTransition?) = { - slideInHorizontally( - initialOffsetX = { fullWidth -> fullWidth }, - animationSpec = tween(300, easing = FastOutSlowInEasing) - ) - } + /** Slide in from the right when a screen is pushed. */ + val defaultEnter: EnterTransition = slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(300, easing = FastOutSlowInEasing) + ) - val defaultExitTrans: (AnimatedContentTransitionScope.() -> ExitTransition?) = { - slideOutHorizontally( - targetOffsetX = { fullWidth -> -fullWidth / 3 }, - animationSpec = tween(300, easing = FastOutSlowInEasing) - ) + fadeOut( - animationSpec = tween(300, easing = FastOutSlowInEasing), - targetAlpha = 0.8f - ) - } + /** Slide partly out to the left and dim when a screen is covered. */ + val defaultExit: ExitTransition = slideOutHorizontally( + targetOffsetX = { fullWidth -> -fullWidth / 3 }, + animationSpec = tween(300, easing = FastOutSlowInEasing) + ) + fadeOut( + animationSpec = tween(300, easing = FastOutSlowInEasing), + targetAlpha = 0.8f + ) + /** Slide back in from the left when the screen above is popped. */ + val defaultPopEnter: EnterTransition = slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth / 3 }, + animationSpec = tween(300, easing = FastOutSlowInEasing) + ) + fadeIn( + animationSpec = tween(300, easing = FastOutSlowInEasing), + initialAlpha = 0.8f + ) + + /** Slide out to the right when a screen is popped. */ + val defaultPopExit: ExitTransition = slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(300, easing = FastOutSlowInEasing) + ) + + val defaultEnterTrans: (AnimatedContentTransitionScope.() -> EnterTransition?) = { defaultEnter } + val defaultExitTrans: (AnimatedContentTransitionScope.() -> ExitTransition?) = { defaultExit } val defaultPopEnterTrans: (AnimatedContentTransitionScope.() -> EnterTransition?) = { - slideInHorizontally( - initialOffsetX = { fullWidth -> -fullWidth / 3 }, - animationSpec = tween(300, easing = FastOutSlowInEasing) - ) + fadeIn( - animationSpec = tween(300, easing = FastOutSlowInEasing), - initialAlpha = 0.8f - ) + defaultPopEnter } - val defaultPopExitTrans: (AnimatedContentTransitionScope.() -> ExitTransition?) = { - slideOutHorizontally( - targetOffsetX = { fullWidth -> fullWidth }, - animationSpec = tween(300, easing = FastOutSlowInEasing) - ) + defaultPopExit } } diff --git a/changelog.d/next/1586.fixed.md b/changelog.d/next/1586.fixed.md new file mode 100644 index 0000000000..9c8b5dccf4 --- /dev/null +++ b/changelog.d/next/1586.fixed.md @@ -0,0 +1 @@ +Receiving USDT now slides between the address, network, amount and fee screens, like the Bitcoin receive flow. diff --git a/journeys/README.md b/journeys/README.md index bbec4108c6..af01ceb10f 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -159,6 +159,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `coin-selection/manual-wallet-switch.xml` | same file, journey name and actions; iOS can open the picker on the wallet switch, Android on the next swipe. The iOS picker has no accessibility identifiers, so assert its visible "Coin Selection" title. | | `payment-requests/requested-resolution-failure.xml` | same file and journey name; both platforms keep the preparing confirmation open across retries and stop automatic retries when it closes. Native progress and request-row identifiers differ. Android's subscription-reminder preparation has no intermediate Send sheet; see `paykit-clock-changes.md`. | | `payment-requests/request-summary.xml` | same file and journey name; Android additionally checks the open Sent receipt's live lifecycle subtitle after payment. iOS keeps a static note/date receipt without that subtitle, so those checks are not ported. | +| `usdt-receive-amount-and-units.xml` | same file and journey name; the Edit slide step (Amount slides in from the right, Back slides it out over the QR) waits on synonymdev/bitkit-ios#1018, since iOS still swaps the USDT receive pages without a transition. Port the step with that fix. | | `usdt-payment-restoration.xml` | same file, journey name and actions; Android restores an unpaid amount draft after process death, while iOS may return to the wallet. A submitted payment must return to the wallet without reopening the paid draft on either platform. | | `payment-requests/success-dismissal.xml` | same file, journey name and actions; both platforms dismiss successful and unpaid requests without a send error or duplicate payment. | | `payment-requests/minimum-onchain-amount.xml` | shared — minimum amount feedback, preparation cleanup, and manual retry use the same journey and `PaymentRequestAmountTooLowToast` identifier on both platforms. | diff --git a/journeys/usdt-receive-amount-and-units.xml b/journeys/usdt-receive-amount-and-units.xml index 92900afd2b..d2508c40b0 100644 --- a/journeys/usdt-receive-amount-and-units.xml +++ b/journeys/usdt-receive-amount-and-units.xml @@ -4,6 +4,7 @@ Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. Open USDT from Home. Verify the USDT balance, teal Tether icon and existing activity rows. Open Receive. Verify Receive Dollars, the USDT tab and Network: Arbitrum One on the QR screen. + Tap Edit. Verify the Amount screen slides in from the right, as Edit Invoice does on the Savings tab. Tap Back and verify it slides out to the right over the QR. Tap Edit, enter 5 and tap Back. Verify the QR has no uint256 amount. Tap Edit. Enter 3.50. Verify the dollar amount and BTC equivalent are displayed, there is no input-unit toggle, the keypad follows the divider, and Continue stays at the bottom without overlap. Continue to the QR. Verify it requests the Arbitrum USDT token, the wallet address and uint256=3500000, never Bitcoin.