diff --git a/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt index 4cf16128c2..d2ebb6eead 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt @@ -41,6 +41,10 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.test.annotations.ComposeUi +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.ui.screens.wallets.usdt.DepositUiState +import to.bitkit.ui.screens.wallets.usdt.UsdtDepositReceiveContent +import to.bitkit.ui.screens.wallets.usdt.ReceivePage import to.bitkit.ui.screens.wallets.usdt.UsdtReceiveContent import to.bitkit.ui.sheets.NewTransactionSheetView import to.bitkit.ui.theme.AppThemeSurface @@ -75,7 +79,8 @@ class UsdtReceivePrivacyTest { val polygonUri = "ethereum:0xc2132D05D31c914a87C6611C10748AEb04B58e8F@137/transfer?address=$deposit" val cases = listOf( ReceiveCase(wallet, "Arbitrum One", arbitrumUri), - ReceiveCase(wallet, "Arbitrum One", "$arbitrumUri&uint256=1234567", "1.234567"), + ReceiveCase(wallet, "Arbitrum One", "$arbitrumUri&uint256=3500000", "3.5", "3.50"), + ReceiveCase(wallet, "Arbitrum One", "$arbitrumUri&uint256=1", "0.000001", "0.000001"), ReceiveCase(deposit, "Polygon", "$polygonUri&uint256=3500000", "3.5"), ReceiveCase(wallet, "Arbitrum One", arbitrumUri), ) @@ -94,7 +99,23 @@ class UsdtReceivePrivacyTest { AppThemeSurface { Column { val receive = current.value - UsdtReceiveContent(receive.address, receive.uri, null, receive.network, {}, {}, receive.amount) + if (receive.inputAmount != null) { + UsdtDepositReceiveContent( + page = ReceivePage.ADDRESS, + onPage = {}, + wallet = UsdtWalletState(address = wallet, receiveUri = arbitrumUri), + receiveError = null, + state = DepositUiState(), + amount = receive.inputAmount, + onNetwork = {}, + onAmount = {}, + onCreate = {}, + onHistory = {}, + onRefresh = {}, + ) + } else { + UsdtReceiveContent(receive.address, receive.uri, null, receive.network, {}, {}, receive.amount) + } } } } @@ -142,7 +163,13 @@ class UsdtReceivePrivacyTest { } } - private data class ReceiveCase(val address: String, val network: String, val uri: String, val amount: String = "") + private data class ReceiveCase( + val address: String, + val network: String, + val uri: String, + val amount: String = "", + val inputAmount: String? = null, + ) @Test fun incomingPaymentHonorsHiddenBalance() { diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 0b9b958700..03aa40e50f 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -2,10 +2,16 @@ package to.bitkit.ui.screens.paymentrequests +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled @@ -17,9 +23,15 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import to.bitkit.models.PaykitAmount @@ -39,15 +51,31 @@ import to.bitkit.repositories.PaykitSubscriptionRecurrence import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.theme.AppThemeSurface +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant @ComposeUi class PaymentRequestsScreenTest { + private val originalLocale = Locale.getDefault() + @get:Rule val composeTestRule = createComposeRule() + @Before + fun setup() { + Locale.setDefault(Locale.US) + } + + @After + fun teardown() { + Locale.setDefault(originalLocale) + } + @Test fun detailsDisablePayWhilePreparationIsPending() { val isPreparing = mutableStateOf(false) @@ -300,6 +328,158 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithTag("PaymentRequestCreate").assertDoesNotExist() } + @Test + fun longReceiptStatusKeepsContactAndExactAmountReadable() { + val status = "Płatność zweryfikowana · 0.001647 USDT" + val asset = mutableStateOf(PaykitAsset.USDT) + val fontScale = mutableStateOf(1.3f) + composeTestRule.setContent { + PaymentRequestsTestSurface { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale.value)) { + Box(Modifier.width(320.dp).padding(horizontal = 16.dp)) { + PaymentRequestCard( + request = request().copy(amount = PaykitAmount(asset.value, 1_647u)), + contact = PubkyProfile.forDisplay(request().counterparty, "Maria", imageUrl = null), + fiatStatus = status, + ) + } + } + } + } + for (scale in listOf(1.3f, 2f)) { + composeTestRule.runOnIdle { fontScale.value = scale } + for (denomination in listOf(PaykitAsset.USDT, PaykitAsset.USD, PaykitAsset.BTC)) { + composeTestRule.runOnIdle { asset.value = denomination } + for (text in listOf("Maria", status)) { + val results = mutableListOf() + composeTestRule.onNodeWithText(text).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + val layout = results.single() + assertFalse(layout.hasVisualOverflow, "$text must fit without truncation") + for (word in text.split(' ')) { + val start = text.indexOf(word) + assertEquals(layout.getLineForOffset(start), layout.getLineForOffset(start + word.length - 1)) + } + } + } + } + } + + @Test + fun shortWordContactKeepsFullNameBesideLongReceipt() { + val name = "Ann Lee" + val status = "Payment verified · 0.001647 USDT" + val width = mutableStateOf(407.dp) + composeTestRule.setContent { + PaymentRequestsTestSurface { + Box(Modifier.width(width.value).padding(horizontal = 16.dp)) { + PaymentRequestCard( + request = request().copy(amount = PaykitAmount(PaykitAsset.USDT, 1_647u)), + contact = PubkyProfile.forDisplay(request().counterparty, name, imageUrl = null), + compactSubtitle = "09:41", + fiatStatus = status, + ) + } + } + } + for (size in listOf(407.dp, 412.dp)) { + composeTestRule.runOnIdle { width.value = size } + for (text in listOf(name, status)) { + val results = mutableListOf() + composeTestRule.onNodeWithText(text).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + assertFalse(results.single().hasVisualOverflow, "$text must fit without truncation at $size") + } + } + } + + @Test + fun shortAmountLeavesRoomForContactNameAtLargeText() { + val name = "Alexandra Payer" + composeTestRule.setContent { + PaymentRequestsTestSurface { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { + Box(Modifier.width(375.dp).padding(horizontal = 16.dp)) { + PaymentRequestCard( + request = request().copy(amount = PaykitAmount(PaykitAsset.USD, 200u)), + contact = PubkyProfile.forDisplay(request().counterparty, name, imageUrl = null), + fiatStatus = "Pending", + ) + } + } + } + } + for (text in listOf(name, "$2.00", "Pending")) { + val results = mutableListOf() + composeTestRule.onNodeWithText(text).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + assertTrue(results.isNotEmpty()) + assertFalse(results.single().hasVisualOverflow, "$text must fit without truncation") + } + } + + @Test + fun longAmountUsesAvailableCardWidthAtLargeText() { + val asset = mutableStateOf(PaykitAsset.BTC) + composeTestRule.setContent { + PaymentRequestsTestSurface { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { + Box(Modifier.width(320.dp).padding(horizontal = 16.dp)) { + PaymentRequestCard( + request = request().copy(amount = PaykitAmount(asset.value, 1_000_000u)), + contact = PubkyProfile.forDisplay(request().counterparty, "Alice", imageUrl = null), + ) + } + } + } + } + for ((value, text) in listOf(PaykitAsset.BTC to "₿ 1 000 000", PaykitAsset.USD to "$10,000.00")) { + composeTestRule.runOnIdle { asset.value = value } + val results = mutableListOf() + composeTestRule.onNodeWithText(text).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + assertTrue(results.isNotEmpty()) + assertFalse(results.single().hasVisualOverflow, "$text must fit without truncation") + assertTrue(results.single().lineCount == 1) + } + } + + @Test + fun bitcoinReceiptStatusUsesCaptionInBothUnitSettings() { + val primary = mutableStateOf(PrimaryDisplay.FIAT) + val status = "Płatność zweryfikowana · 4,12485 USDT" + composeTestRule.setContent { + PaymentRequestsTestSurface(CurrencyState(primaryDisplay = primary.value)) { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { + Box(Modifier.width(360.dp)) { + PaymentRequestCard( + request = request().copy(amount = PaykitAmount(PaykitAsset.BTC, 5_000u)), + contact = PubkyProfile.forDisplay(request().counterparty, "Alice", imageUrl = null), + fiatStatus = status, + ) + } + } + } + } + val layouts = PrimaryDisplay.entries.map { value -> + composeTestRule.runOnIdle { primary.value = value } + val results = mutableListOf() + composeTestRule.onNodeWithText(status).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + results.single().also { layout -> + assertFalse(layout.hasVisualOverflow) + val wordStart = status.indexOf("zweryfikowana") + val wordEnd = wordStart + "zweryfikowana".lastIndex + assertTrue(layout.getLineForOffset(wordStart) == layout.getLineForOffset(wordEnd)) + } + } + assertTrue(layouts.first().layoutInput.style.fontSize == layouts.last().layoutInput.style.fontSize) + } + private fun setOutgoingRequestContent(primaryDisplay: PrimaryDisplay) { val outgoing = request(id = "outgoing").copy( createdAt = Clock.System.now(), diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt new file mode 100644 index 0000000000..5842e06eb6 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt @@ -0,0 +1,58 @@ +package to.bitkit.ui.screens.wallets.send + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import to.bitkit.models.PubkyProfile +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertEquals + +@ComposeUi +class SendContactSelectScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun resolvingContactShowsProgressAndPreventsAnotherSelection() { + val state = mutableStateOf( + SendContactSelectUiState( + contacts = persistentListOf(PubkyProfile.placeholder("alice"), PubkyProfile.placeholder("bob")), + resolvingPublicKey = "alice", + ) + ) + var selected: String? = null + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + SendContactSelectContent(uiState = state.value, onContactClick = { selected = it }) + } + } + } + composeTestRule.onNodeWithTag("SendContactLoading_alice", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithTag("SendContactLoading_bob", useUnmergedTree = true).assertDoesNotExist() + composeTestRule.onNodeWithTag("SendContact_alice").assertIsNotEnabled().assert( + SemanticsMatcher.expectValue(SemanticsProperties.ProgressBarRangeInfo, ProgressBarRangeInfo.Indeterminate) + ) + composeTestRule.onNodeWithTag("SendContact_bob").assertIsNotEnabled().assert( + SemanticsMatcher.keyNotDefined(SemanticsProperties.ProgressBarRangeInfo) + ) + composeTestRule.runOnIdle { state.value = state.value.copy(resolvingPublicKey = null) } + composeTestRule.onNodeWithTag("SendContactLoading_alice", useUnmergedTree = true).assertDoesNotExist() + composeTestRule.onNodeWithTag("SendContact_bob").assertIsEnabled().performClick() + composeTestRule.runOnIdle { assertEquals("bob", selected) } + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index 07a29696aa..113c3cd529 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import to.bitkit.models.BITCOIN_SYMBOL @@ -59,6 +61,8 @@ fun MoneyCell( prefix: String = "", showBitcoinSymbol: Boolean = true, fiatReplacement: String? = null, + maxAmountLines: Int = Int.MAX_VALUE, + statusModifier: Modifier = Modifier, ) { val currencies = LocalCurrencies.current val primaryUnit = currencies.primaryDisplay @@ -76,7 +80,9 @@ fun MoneyCell( ) val primaryIsReplaced = fiatReplacement != null && primaryUnit == PrimaryDisplay.FIAT val primary = if (primaryIsReplaced) fiatReplacement else primaryMoney - val secondary = if (fiatReplacement != null && secondaryUnit == PrimaryDisplay.FIAT) { + val secondaryIsReplaced = fiatReplacement != null && secondaryUnit == PrimaryDisplay.FIAT + val amountOverflow = if (maxAmountLines == Int.MAX_VALUE) TextOverflow.Clip else TextOverflow.Ellipsis + val secondary = if (secondaryIsReplaced) { fiatReplacement } else { secondaryMoney @@ -88,16 +94,31 @@ fun MoneyCell( modifier = modifier, ) { primary?.let { text -> - BodyMSB( - text = (if (primaryIsReplaced) text else "$prefix$text").withAccent(accentColor = Colors.White64), - modifier = Modifier.testTag("MoneyPrimary"), - ) + if (primaryIsReplaced) { + CaptionB( + text = text, + color = Colors.White64, + textAlign = TextAlign.End, + modifier = statusModifier.testTag("MoneyPrimary") + ) + } else { + BodyMSB( + text = "$prefix$text".withAccent(accentColor = Colors.White64), + textAlign = TextAlign.End, + maxLines = maxAmountLines, + overflow = amountOverflow, + modifier = Modifier.testTag("MoneyPrimary") + ) + } } secondary?.let { text -> CaptionB( text = text.withAccent(accentColor = Colors.White64), color = Colors.White64, - modifier = Modifier.testTag("MoneySecondary"), + textAlign = TextAlign.End, + maxLines = if (secondaryIsReplaced) Int.MAX_VALUE else maxAmountLines, + overflow = amountOverflow, + modifier = (if (secondaryIsReplaced) statusModifier else Modifier).testTag("MoneySecondary") ) } } diff --git a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt index 7623cd1de5..3c610e71b0 100644 --- a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt +++ b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.ui.theme.Colors @@ -42,16 +44,22 @@ fun PaykitAmountDisplay(amount: PaykitAmount, modifier: Modifier = Modifier, pre @Composable fun PaykitAmountCell( amount: PaykitAmount, + modifier: Modifier = Modifier, prefix: String = "", status: String? = null, - showBitcoinSymbol: Boolean = true + showBitcoinSymbol: Boolean = true, + maxAmountLines: Int = Int.MAX_VALUE, + statusModifier: Modifier = Modifier, ) { if (amount.asset == PaykitAsset.BTC) { MoneyCell( sats = amount.atomic.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), prefix = prefix, showBitcoinSymbol = showBitcoinSymbol, - fiatReplacement = status + fiatReplacement = status, + maxAmountLines = maxAmountLines, + statusModifier = statusModifier, + modifier = modifier ) } else { val locale = LocalConfiguration.current.locales[0] @@ -62,9 +70,20 @@ fun PaykitAmountCell( usdDisplayAmount(amount.atomic, locale) } } - Column(horizontalAlignment = Alignment.End) { - BodyMSB(text = prefix + "$" + displayAmount) - status?.let { BodyS(text = it, color = Colors.White64) } + Column(horizontalAlignment = Alignment.End, modifier = modifier) { + BodyMSB( + text = prefix + "$" + displayAmount, + maxLines = maxAmountLines, + overflow = if (maxAmountLines == Int.MAX_VALUE) TextOverflow.Clip else TextOverflow.Ellipsis, + ) + status?.let { + BodyS( + text = it, + color = Colors.White64, + textAlign = TextAlign.End, + modifier = statusModifier + ) + } } } } diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt index ddb83c7426..dfbc964401 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt @@ -11,9 +11,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics @@ -32,6 +35,7 @@ fun PubkyContactRow( modifier: Modifier = Modifier, isSelected: Boolean? = null, isEnabled: Boolean = true, + isLoading: Boolean = false, verticalPadding: Dp = 12.dp, selectionColor: Color = Colors.PubkyGreen, ) { @@ -41,17 +45,12 @@ fun PubkyContactRow( modifier = modifier .fillMaxWidth() .clickableAlpha(enabled = isEnabled, onClick = onClick) - .then( - if (isSelected == null) { - Modifier - } else { - Modifier.semantics { - role = Role.RadioButton - selected = isSelected - if (!isEnabled) disabled() - } - } - ) + .semantics { + role = if (isSelected == null) Role.Button else Role.RadioButton + isSelected?.let { selected = it } + if (!isEnabled) disabled() + if (isLoading) progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate + } .padding(vertical = verticalPadding) ) { PubkyContactAvatar(profile = profile) @@ -71,7 +70,11 @@ fun PubkyContactRow( overflow = TextOverflow.Ellipsis, ) } - if (isSelected == true) { + if (isLoading) { + GradientCircularProgressIndicator( + modifier = Modifier.size(24.dp).testTag("SendContactLoading_${profile.publicKey}") + ) + } else if (isSelected == true) { Icon( painter = painterResource(R.drawable.ic_check), contentDescription = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index e1e04ed29f..619dbd81ed 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets @@ -19,6 +21,7 @@ import androidx.compose.foundation.layout.navigationBars 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card @@ -597,34 +600,42 @@ internal fun PaymentRequestCard( .clickableAlpha(enabled = onClick != null) { onClick?.invoke() } .testTag("PaymentRequestRow-${request.paymentRequestId}") ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp), + FlowRow( + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(16.dp) ) { - if (request.showsPaymentRailIcon(isOutgoingPayment)) { - PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) - } else { - PubkyContactAvatar(profile = displayContact, size = 40.dp) - } - Column(modifier = Modifier.weight(1f)) { - BodyMSB( - text = title ?: displayContact.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BodyS( - text = subtitle, - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.weight(1f).width(IntrinsicSize.Max).align(Alignment.CenterVertically) + ) { + if (request.showsPaymentRailIcon(isOutgoingPayment)) { + PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) + } else { + PubkyContactAvatar(profile = displayContact, size = 40.dp) + } + Column(modifier = Modifier.weight(1f)) { + BodyMSB( + text = title ?: displayContact.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyS( + text = subtitle, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } PaykitAmountCell( amount = request.amount, prefix = amountPrefix, showBitcoinSymbol = showBitcoinSymbol, status = paymentRequestAmountStatus(request, receipt, fiatStatus), + maxAmountLines = 1, + statusModifier = Modifier.width(IntrinsicSize.Max), ) } if (onPay != null || onDismiss != null) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt index 7c127898e0..a4bf548b04 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Job import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.repositories.Endpoint @@ -36,7 +37,7 @@ import to.bitkit.ui.theme.Colors fun SendContactSelectScreen( viewModel: SendContactSelectViewModel, onBack: () -> Unit, - onOpenPayment: (String, String, PrivatePaykitPaymentContext?, List) -> Unit, + onOpenPayment: (String, String, PrivatePaykitPaymentContext?, List) -> Job?, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -44,7 +45,9 @@ fun SendContactSelectScreen( viewModel.effects.collect { when (it) { is SendContactSelectEffect.OpenPayment -> - onOpenPayment(it.paymentRequest, it.publicKey, it.privatePaymentContext, it.endpoints) + viewModel.onPaymentOpening( + onOpenPayment(it.paymentRequest, it.publicKey, it.privatePaymentContext, it.endpoints) + ) } } } @@ -57,7 +60,7 @@ fun SendContactSelectScreen( } @Composable -private fun SendContactSelectContent( +internal fun SendContactSelectContent( uiState: SendContactSelectUiState, onBack: () -> Unit = {}, onContactClick: (String) -> Unit = {}, @@ -100,6 +103,7 @@ private fun SendContactSelectContent( else -> SendContactList( contacts = uiState.contacts, + resolvingPublicKey = uiState.resolvingPublicKey, onContactClick = onContactClick, ) } @@ -109,6 +113,7 @@ private fun SendContactSelectContent( @Composable private fun SendContactList( contacts: ImmutableList, + resolvingPublicKey: String?, onContactClick: (String) -> Unit, ) { LazyColumn( @@ -119,6 +124,8 @@ private fun SendContactList( items(contacts, key = { it.publicKey }) { contact -> PubkyContactRow( profile = contact, + isEnabled = resolvingPublicKey == null, + isLoading = resolvingPublicKey == contact.publicKey, onClick = { onContactClick(contact.publicKey) }, modifier = Modifier.testTag("SendContact_${contact.publicKey}") ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt index bc31cc868d..8aac9e0542 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt @@ -9,11 +9,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R @@ -37,8 +37,8 @@ class SendContactSelectViewModel @Inject constructor( private val _uiState = MutableStateFlow(SendContactSelectUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _effects = MutableSharedFlow(extraBufferCapacity = 1) - val effects = _effects.asSharedFlow() + private val pendingPayment = MutableStateFlow(null) + val effects = pendingPayment.filterNotNull() init { viewModelScope.launch { @@ -64,36 +64,53 @@ class SendContactSelectViewModel @Inject constructor( } fun payContact(publicKey: String) { - if (_uiState.value.isResolvingPayment) return + if (_uiState.value.resolvingPublicKey != null) return + _uiState.update { it.copy(resolvingPublicKey = publicKey) } viewModelScope.launch { - _uiState.update { it.copy(isResolvingPayment = true) } - privatePaykitRepo.beginSavedContactPayment(publicKey) - .onSuccess { result -> - when (result) { - is PublicPaykitPaymentResult.Opened -> - _effects.emit( - SendContactSelectEffect.OpenPayment( - result.paymentRequest, - publicKey, - result.privatePaymentContext, - result.endpoints, - ) - ) - PublicPaykitPaymentResult.NoEndpoint -> - showPayError(R.string.slashtags__error_pay_empty_msg) - PublicPaykitPaymentResult.NotOpened -> - showPayError(R.string.slashtags__error_pay_not_opened_msg) - PublicPaykitPaymentResult.PrivateLinkPending, - PublicPaykitPaymentResult.WaitingForUpdatedPaymentList, - -> - showPayError(R.string.slashtags__error_pay_waiting_msg) + var openingPayment = false + try { + privatePaykitRepo.beginSavedContactPayment(publicKey) + .onSuccess { result -> + when (result) { + is PublicPaykitPaymentResult.Opened -> { + openingPayment = true + pendingPayment.update { + SendContactSelectEffect.OpenPayment( + result.paymentRequest, + publicKey, + result.privatePaymentContext, + result.endpoints, + ) + } + } + PublicPaykitPaymentResult.NoEndpoint -> + showPayError(R.string.slashtags__error_pay_empty_msg) + PublicPaykitPaymentResult.NotOpened -> + showPayError(R.string.slashtags__error_pay_not_opened_msg) + PublicPaykitPaymentResult.PrivateLinkPending, + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList, + -> + showPayError(R.string.slashtags__error_pay_waiting_msg) + } } - } - .onFailure { - Logger.warn("Failed to begin contact payment", it, context = TAG) - showPayError(R.string.slashtags__error_pay_not_opened_msg) - } - _uiState.update { it.copy(isResolvingPayment = false) } + .onFailure { + Logger.warn("Failed to begin contact payment", it, context = TAG) + showPayError(R.string.slashtags__error_pay_not_opened_msg) + } + } finally { + if (!openingPayment) _uiState.update { it.copy(resolvingPublicKey = null) } + } + } + } + + fun onPaymentOpening(scanJob: Job?) { + pendingPayment.update { null } + viewModelScope.launch { + try { + scanJob?.join() + } finally { + _uiState.update { it.copy(resolvingPublicKey = null) } + } } } @@ -114,7 +131,7 @@ class SendContactSelectViewModel @Inject constructor( data class SendContactSelectUiState( val contacts: ImmutableList = persistentListOf(), val isLoading: Boolean = false, - val isResolvingPayment: Boolean = false, + val resolvingPublicKey: String? = null, ) sealed interface SendContactSelectEffect { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt index 5464b5a1cc..ab5471fda7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size @@ -119,7 +120,7 @@ internal fun UsdtActivityDetail( var showDetails by rememberSaveable(transfer.id) { mutableStateOf(false) } val scrollState = rememberScrollState() LaunchedEffect(transfer.id, showDetails) { scrollState.scrollTo(0) } - Column(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { AppTopBar( titleText = stringResource( transfer.detailTitleResource 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 8562cdd6bb..1954bc033a 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 @@ -147,7 +147,7 @@ internal fun ColumnScope.UsdtDepositReceiveScreen( } ) ) { - Content( + UsdtDepositReceiveContent( page = page, onPage = { page = it }, wallet = wallet, @@ -189,7 +189,7 @@ internal fun ColumnScope.UsdtDepositReceiveScreen( } @Composable -private fun ColumnScope.Content( +internal fun ColumnScope.UsdtDepositReceiveContent( page: ReceivePage, onPage: (ReceivePage) -> Unit, wallet: UsdtWalletState, @@ -240,7 +240,8 @@ private fun ColumnScope.Content( uri = state.address?.uri ?: uri, error = receiveError, network = state.network?.label ?: "Arbitrum One", - amount = state.address?.let { usdtFormatAmount(it.amount) } ?: amount, + amount = state.address?.let { usdtFormatAmount(it.amount) } + ?: atomic?.let { usdtFormatAmount(it) }.orEmpty(), onEdit = onRefresh, onNetwork = { onPage(ReceivePage.NETWORKS) }, ) @@ -248,7 +249,7 @@ private fun ColumnScope.Content( } } -private enum class ReceivePage { ADDRESS, NETWORKS, AMOUNT, FEES } +internal enum class ReceivePage { ADDRESS, NETWORKS, AMOUNT, FEES } @Composable private fun ColumnScope.NetworkSelector( diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt new file mode 100644 index 0000000000..9f45c31d92 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt @@ -0,0 +1,97 @@ +package to.bitkit.ui.screens.wallets.send + +import android.content.Context +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitPaymentResult +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SendContactSelectViewModelTest : BaseUnitTest() { + @Test + fun `contact stays busy until resolution finishes and can be retried`() = test { + val context: Context = mock() + val contacts: PubkyRepo = mock() + val payments: PrivatePaykitRepo = mock() + whenever(context.getString(any())).thenReturn("") + whenever(contacts.contacts).thenReturn(MutableStateFlow(emptyList())) + whenever(contacts.isLoadingContacts).thenReturn(MutableStateFlow(false)) + val result = CompletableDeferred>() + whenever(payments.beginSavedContactPayment("alice")).doSuspendableAnswer { result.await() } + val model = SendContactSelectViewModel(context, contacts, payments) + + model.payContact("alice") + model.payContact("bob") + advanceUntilIdle() + assertEquals("alice", model.uiState.value.resolvingPublicKey) + verify(payments, times(0)).beginSavedContactPayment("bob") + + result.complete(Result.success(PublicPaykitPaymentResult.NoEndpoint)) + advanceUntilIdle() + assertNull(model.uiState.value.resolvingPublicKey) + model.payContact("alice") + advanceUntilIdle() + verify(payments, times(2)).beginSavedContactPayment("alice") + assertNull(model.uiState.value.resolvingPublicKey) + } + + @Test + fun `contact stays busy through navigation and unlocks on completion or cancellation`() = test { + val context: Context = mock() + val contacts: PubkyRepo = mock() + val payments: PrivatePaykitRepo = mock() + whenever(context.getString(any())).thenReturn("") + whenever(contacts.contacts).thenReturn(MutableStateFlow(emptyList())) + whenever(contacts.isLoadingContacts).thenReturn(MutableStateFlow(false)) + whenever(payments.beginSavedContactPayment("alice")) + .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("payment"))) + val model = SendContactSelectViewModel(context, contacts, payments) + + for (cancel in listOf(false, true)) { + val received = CompletableDeferred() + model.payContact("alice") + advanceUntilIdle() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { received.complete(model.effects.first()) } + assertTrue(received.isCompleted, "Opening must survive a gap between screen collectors") + assertEquals("alice", (received.await() as SendContactSelectEffect.OpenPayment).publicKey) + model.payContact("bob") + assertEquals("alice", model.uiState.value.resolvingPublicKey) + + val navigation = Job() + model.onPaymentOpening(navigation) + advanceUntilIdle() + val reopened = CompletableDeferred() + val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + reopened.complete(model.effects.first()) + } + assertFalse(reopened.isCompleted, "Recreating the screen must not open the same payment twice") + collector.cancel() + model.payContact("bob") + assertEquals("alice", model.uiState.value.resolvingPublicKey) + verify(payments, times(0)).beginSavedContactPayment("bob") + if (cancel) navigation.cancel() else navigation.complete() + advanceUntilIdle() + assertNull(model.uiState.value.resolvingPublicKey) + } + verify(payments, times(2)).beginSavedContactPayment("alice") + } +} diff --git a/changelog.d/next/1523.fixed.md b/changelog.d/next/1523.fixed.md new file mode 100644 index 0000000000..ec422dff3e --- /dev/null +++ b/changelog.d/next/1523.fixed.md @@ -0,0 +1 @@ +Improved USDT payment layout, contact loading feedback and receive sharing amounts. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 0916655715..9aa106fdd0 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -47,6 +47,16 @@ existing foreground synchronization. A protocol proof changes the note-free subt "Proof submitted"; a nonblank note still takes precedence. This is not inferred from a chain payment. iOS keeps a static note/date receipt with no lifecycle subtitle, so these receipt-status steps do not apply there. +The USDT history step in `request-summary.xml` requires both instances opted in, a payer funded with +local test USDT, and two completed requests (one USD-denominated and one BTC-denominated), each paid +in USDT with a verified receipt. Establish these using the local USDT fixture before the journey and +record the USD request amount and each payer activity detail's rounded Payment value. The USD +receipt matches the request amount. The BTC request's receipt retains up to six decimals and rounds +to that Payment value (shown as less than 0.01 below one cent), not the fee-inclusive activity total. +The two payments need not have equal amounts. iOS uses a supported 375-point device with enlarged +Dynamic Type; Android uses 320 dp. `contact-request- +or-pay.xml` also needs a third linked contact that publishes no payment endpoints, so the failure +and retry steps are reproducible. `contact-request-or-pay.xml` uses the same two-instance setup and starts from the payer's Contact Detail screen, opened through the `bitkit://contact` deeplink. Its timing step assumes the payer has been running for about a minute: right after launch, the Paykit session restore and link refresh hold the SDK and can push the Pay step well past the budget. diff --git a/journeys/payment-requests/contact-request-or-pay.xml b/journeys/payment-requests/contact-request-or-pay.xml index 11d800076a..1f15998980 100644 --- a/journeys/payment-requests/contact-request-or-pay.xml +++ b/journeys/payment-requests/contact-request-or-pay.xml @@ -1,6 +1,6 @@ - Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. + Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded; see README.md. Also save a third linked contact that publishes no payment endpoints, for the resolution-failure check. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. Open bitkit://contact?pubky=<requester-public-key> on the payer @@ -16,5 +16,6 @@ Verify the Request or Pay sheet (testTag "RequestOrPaySheet") appears Tap Request on the sheet Verify the Payment Request amount screen (testTag "PaymentRequestAmount") opens + Open Send and choose Contact. Tap a saved contact and verify its row shows loading feedback while payment details resolve; other contacts cannot be selected meanwhile. When resolution finishes, verify the amount screen opens. Cancel without paying. Reopen Send, choose Contact and select the prepared contact with no payment endpoints. Verify the error is shown and contact selection becomes available for retry. Select the original payable contact and verify its amount screen opens, then cancel without paying. diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 0ccf8d31d1..03a7964cc2 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -1,6 +1,6 @@ - Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded to cover 21,000 sats; see README.md. Android also checks that the requester's open Sent receipt follows protocol history when no note is present. iOS keeps a static note/date receipt and does not display this lifecycle subtitle. + Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded to cover 21,000 sats plus fees and local test USDT payments. Before starting, use the local USDT fixture to establish two completed requests in the requester's history: one USD-denominated and one BTC-denominated, each paid in USDT with a verified receipt. Record the USD request amount and each payer activity detail's rounded Payment value for comparison; the amounts need not be equal. Both instances must have USDT enabled. The iOS layout check uses a supported 375-point-wide device with enlarged Dynamic Type; Android uses 320 dp. See README.md. Android also checks that the requester's open Sent receipt follows protocol history when no note is present. iOS keeps a static note/date receipt and does not display this lifecycle subtitle. On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" @@ -19,5 +19,6 @@ On the payer, wait for preparation to finish and swipe to pay the 5,000 sats request On the requester, close any received-payment sheet covering the Sent receipt, leaving the receipt open underneath Keep the requester foregrounded through its existing Paykit synchronization; after the protocol payment proof arrives, verify the same Sent receipt shows "Proof submitted" instead of "Waiting for payment", while its PAYMENT REQUESTED heading remains unchanged + Open the prepared USD-denominated request in payment history. On a 320-dp-wide Android screen with enlarged text and a long translated verification status, verify a short contact name such as Maria and every status word remain complete. The amount and receipt may move below the contact when needed. Repeat at the default text size and verify short amounts and statuses remain beside the contact when they fit. For the USD request, verify the receipt equals the request amount. Repeat the layout checks for the prepared BTC-denominated request paid in USDT. Verify its receipt shows the USDT amount with up to six decimals in the selected locale's separator and rounds to the Payment value in the payer's activity detail (shown as less than 0.01 below one cent). Do not compare with the fee-inclusive activity total. diff --git a/journeys/usdt-bridge-details.xml b/journeys/usdt-bridge-details.xml index f153e2ccb5..efeabeea65 100644 --- a/journeys/usdt-bridge-details.xml +++ b/journeys/usdt-bridge-details.xml @@ -16,6 +16,7 @@ Return to the USDT activity list. Verify Pending uses a yellow hourglass and 16% yellow circle, Failed uses a red x-circle and 16% red circle, and Transferring uses the teal transfer arrows. Sent and Received remain teal. Open USDT Receive and inject one confirmed incoming USDT transfer through the isolated fixture. Verify Receive is replaced by one received celebration. Dismiss it and verify the same transfer is not celebrated again; a different subsequent transfer must still be shown. Open USDT Receive, then Network. Verify Cross-Network Deposits (id "UsdtDepositHistory") is a full-width primary button pinned over the bottom of the network list. Scroll the list and verify the rows and artwork pass behind the button while it stays fixed. Scroll to the end and verify the last content can be brought above the button. Repeat with large text, verify the button label is not clipped, then tap it and verify deposit history opens before any Arbitrum receipt exists. + On iOS, select the largest accessibility text size. Open Cross-Network Deposits and verify each row shows the full source network, status and amount without truncation. Open a deposit and verify Status and Receiving Network stack vertically and remain readable. Return to the default text size and verify the compact row and two-column summary return. Using only an isolated refund fixture, submit a refund address that the fixture rejects. Verify UsdtRefundError appears immediately below UsdtRefundAddress and remains visible through two background detail refreshes. Edit the address and verify the error clears; retry a rejected address and verify it returns. Change the selected deposit and verify the previous refund error is absent. At the largest system text size, open Details on a refundable deposit and dismiss the keyboard. Verify Request refund, Hide details, Contact support and Copy details stack vertically, can be scrolled into view and have complete readable labels. Return to normal text size and verify the paired actions share their original rows. diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index 9d33cdfe2a..a9751a1bde 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -18,6 +18,7 @@ Enter another owned wallet’s Arbitrum address, manually enter the entire available USDT balance and continue. Verify payment review stays closed and an actionable fee or balance error asks for a smaller amount: "Not enough USDT to cover the amount and fees." or "Unable to pay the transaction fee in USDT. Try a smaller amount." Reduce the amount, reach review and close without signing. With an empty wallet and no pending transfers, turn USDT off in Settings. Copy an owned bare USDT address, return to the app, and verify no automatic payment or enable prompt. Repeat with an amount-bearing USDT payment URI. Copy a valid Bitcoin address and a Lightning invoice in turn; verify their normal clipboard prompts still appear and cancel without paying. With USDT off, explicitly open the same USDT URI and verify the designed Enable USDT modal appears. Decline and verify a quiet return with no stuck spinner. Scan its QR, enable USDT, and verify the original recipient and exact amount resume. Cancel without paying. Turn USDT off again and repeat the explicit scan, decline and enable checks with the owned bare 0x address; verify the recipient resumes with an empty amount. + On iOS with camera permission enabled and the largest accessibility text size, open USDT Send. Start an upward drag inside the camera preview and verify the recipient screen scrolls so Paste, Enter Manually and the network warning are reachable. Verify the photo picker and flashlight controls still respond. Restore the default text size without sending. Enable Read Clipboard, open the main scanner and explicitly paste a copied USDT address or payment URI. If the system asks for paste permission, allow it. Verify exactly one USDT send flow opens with the expected recipient and amount, with no competing clipboard alert or stuck scanner. Cancel, return to Home and verify the same explicit paste does not trigger another clipboard prompt. On a compact display with large text, open USDT Send and select address scanning. Verify the camera remains usable with one scanner heading, one paste action, gallery import and flash controls. Verify denied camera permission offers its normal recovery action and the rest of the send controls remain reachable by scrolling. Cancel without paying. At the largest system text size, open USDT Send on a compact display. Verify the selected network name is fully readable. Deny camera permission and scroll to Enable camera; verify its full label is visible and the action remains usable. Select BNB Smart Chain when available and verify its complete network name is readable. Cancel without paying. diff --git a/journeys/usdt-receive-amount-and-units.xml b/journeys/usdt-receive-amount-and-units.xml index 857d682e1e..92900afd2b 100644 --- a/journeys/usdt-receive-amount-and-units.xml +++ b/journeys/usdt-receive-amount-and-units.xml @@ -5,7 +5,7 @@ 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, enter 5 and tap Back. Verify the QR has no uint256 amount. - Tap Edit. Enter 3.5. 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. + 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. Tap Edit, enter a different amount and tap Back. Verify the QR still requests uint256=3500000. Tap Copy on the QR screen and verify the clipboard contains only the receiving address. Tap the QR and verify it copies the same address, while the QR still encodes the payment URI including uint256=3500000. diff --git a/journeys/usdt-send-review-and-details.xml b/journeys/usdt-send-review-and-details.xml index 1c1543f028..b1513424f4 100644 --- a/journeys/usdt-send-review-and-details.xml +++ b/journeys/usdt-send-review-and-details.xml @@ -1,5 +1,5 @@ - Use an onboarded wallet with USDT, a fresh BTC/USD rate, an owned recipient and existing sent/received activity. Quotes require the service with MAX support. No payment is submitted. + Use an onboarded wallet with USDT, a fresh BTC/USD rate, owned EVM and Solana recipients and existing sent/received activity. Save a linked contact that advertises Bitcoin, Lightning and USDT receiving methods. Have an unpaid one-time request and a due subscription payment from that contact, each permitting USDT. Pair a hardware wallet for the hardware-option check. Use the local USDT fixture for funding, controlled quotes and submitted-payment states. Quotes require the service with MAX support. No payment is submitted. Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. Open USDT Send and enter the owned recipient. On Amount, verify the BTC equivalent above the dollar amount at the top, then the free space, then Available balance, USDT badge, MAX and network pill right above the keypad, and Continue right below the keypad, laid out as on the Bitcoin Amount screen. Enter a six-decimal amount and verify no precision is lost. With a large available balance and BNB Smart Chain selected, verify both values remain fully readable; the controls may wrap onto a second row. @@ -14,5 +14,6 @@ Open an existing sent USDT activity. Verify the heading is Sent USDT, the signed amount includes the actual fee, and summary rows show Status/Network, Date/Time and Payment/Fee with teal icons. Open Details and verify transaction ID, known sender, recipient and source/destination networks; Open Block Explorer stays at the bottom. Open an existing received activity. Verify Received USDT and a positive amount. Verify Details shows the verified sender when available and does not invent one for older unknown records. Check pending, failed and transferring rows use their matching icons, and transferring shows its destination. Prepare a contact payment that accepts multiple payment methods. Open the payment-source button and verify the balance menu shows Savings, Spending, any paired hardware wallet and USDT. Unsupported methods are disabled and shown at 30% opacity. Verify the menu has 24px outer padding, 24px asset icons, 13/18 uppercase labels and 17/22 balances, with no extra transfer icon next to Spending. Select a supported method and verify its label, available balance and payable amount update. Cancel without paying. + On Android, repeat contact, one-time request and recurring request USDT confirmation on a compact device with three-button navigation, then gesture navigation. Verify Swipe To Pay stays above the navigation area in both collapsed and expanded views. Open incoming activity Details and verify Cross-Network Deposits remains fully above the navigation area. Cancel without paying.