Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import androidx.compose.ui.test.swipeRight
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.synonym.paykit.PaymentRequestLifecycleState
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
Expand All @@ -36,9 +37,14 @@ import org.junit.Test
import to.bitkit.models.FeeRate
import to.bitkit.models.PubkyProfile
import to.bitkit.repositories.PaykitPaymentRequest
import to.bitkit.repositories.PaykitRecurrenceUnit
import to.bitkit.repositories.PaykitSubscription
import to.bitkit.repositories.PaykitSubscriptionMetadata
import to.bitkit.repositories.PaykitSubscriptionRecurrence
import to.bitkit.test.annotations.ComposeUi
import to.bitkit.ui.components.Sheet
import to.bitkit.ui.components.SheetHost
import to.bitkit.ui.screens.subscriptions.SubscriptionFirstPaymentProgress
import to.bitkit.ui.shared.modifiers.sheetHeight
import to.bitkit.ui.sheets.SendRoute
import to.bitkit.ui.theme.AppThemeSurface
Expand All @@ -47,6 +53,7 @@ import to.bitkit.viewmodels.SendMethod
import to.bitkit.viewmodels.SendUiState
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.ExperimentalTime

@HiltAndroidTest
Expand Down Expand Up @@ -155,7 +162,65 @@ class SendConfirmScreenTest {
}

@Test
fun initialOnchainSubscriptionShowsFeeBeforeConfirmation() {
fun initialOnchainSubscriptionKeepsReviewLayoutWhilePaying() {
val startsAt = Clock.System.now()
val subscription = PaykitSubscription(
paymentRequestId = "subscription-id",
counterparty = "payee",
amountValue = "0.00003",
amountSats = 3_000uL,
note = "Journey Sub",
createdAt = startsAt,
proposalExpiresAt = null,
recurrence = PaykitSubscriptionRecurrence(
every = 1,
unit = PaykitRecurrenceUnit.Month,
startsAt = startsAt,
anchor = startsAt,
endsAt = null,
),
metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()),
acceptedPaymentEndpointIdentifiers = listOf("btc-regtest-p2wpkh"),
lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING,
paidPeriods = emptyList(),
)
val state = SendUiState(
amount = 3_000u,
payMethod = SendMethod.ONCHAIN,
isAmountInputValid = true,
isInitialSubscriptionPayment = true,
initialSubscriptionPaymentAutoStartPending = true,
onchainFeeUi = OnchainFeeUi(rate = FeeRate.NORMAL, sats = 422),
)
composeTestRule.setContent {
AppThemeSurface {
CompositionLocalProvider(LocalInspectionMode provides true) {
SendConfirmContent(
uiState = state,
isNodeRunning = true,
isLoading = false,
showBiometrics = false,
autoPayContent = {
SubscriptionFirstPaymentProgress(
subscription = subscription,
contact = PubkyProfile.placeholder("payee"),
)
},
)
}
}
}

composeTestRule.onNodeWithText("Journey Sub").assertIsDisplayed()
composeTestRule.onNodeWithTag("GRAB").assertIsDisplayed()
composeTestRule.onNodeWithTag("GRAB").performTouchInput { swipeRight() }
composeTestRule.onNodeWithText("Journey Sub").assertIsDisplayed()
composeTestRule.onNodeWithTag("SendConfirmToggleDetails").assertDoesNotExist()
composeTestRule.onNodeWithText("Swipe To Subscribe & Pay").assertDoesNotExist()
}

@Test
fun initialOnchainSubscriptionShowsProgressWhenSubscriptionIsMissing() {
val state = SendUiState(
amount = 3_000u,
payMethod = SendMethod.ONCHAIN,
Expand All @@ -172,16 +237,13 @@ class SendConfirmScreenTest {
isNodeRunning = true,
isLoading = false,
showBiometrics = false,
initialShowDetails = true,
)
}
}
}

composeTestRule.onNodeWithTag("SendConfirmAssetButton").assertIsDisplayed()
composeTestRule.onNodeWithText("422", substring = true).assertIsDisplayed()
composeTestRule.onNodeWithTag("SendConfirmToggleDetails").assertIsDisplayed()
composeTestRule.onNodeWithText("Swipe To Subscribe & Pay").assertIsDisplayed()
composeTestRule.onNodeWithTag("GRAB").assertDoesNotExist()
composeTestRule.onNodeWithText("Swipe To Subscribe & Pay").assertDoesNotExist()
}

@Test
Expand Down
8 changes: 7 additions & 1 deletion app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ fun SwipeToConfirm(
val maxPanX = if (swiperWidth == 0f) 1f else swiperWidth - with(LocalDensity.current) { CircleSize.toPx() }

val panX = remember { Animatable(0f) }
val startedConfirmed = remember { confirmed }
val loadingOpacity = remember { Animatable(0f) }
val contentAlpha = if (enabled || loading) 1f else 0.5f

Expand All @@ -90,8 +91,13 @@ fun SwipeToConfirm(
}

LaunchedEffect(confirmed, maxPanX) {
val target = if (confirmed) maxPanX else 0f
if (startedConfirmed && confirmed) {
panX.snapTo(target)
return@LaunchedEffect
}
panX.animateTo(
targetValue = if (confirmed) maxPanX else 0f,
targetValue = target,
animationSpec = spring()
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
Expand Down Expand Up @@ -637,6 +638,11 @@ private fun SubscriptionDetailFooter(
}
}

/** Figma draws the review clock at 288dp inside its 256dp slot, rotated 15 degrees and shifted 7dp left. */
private const val CLOCK_SCALE = 288f / 256f
private const val CLOCK_ROTATION_DEGREES = 15f
private val CLOCK_OFFSET_X = (-7).dp

@Composable
fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRoute) {
var route by remember(initialRoute) { mutableStateOf(initialRoute) }
Expand Down Expand Up @@ -672,11 +678,9 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout
VerticalSpacer(16.dp)
}
} else {
val payOnAcceptance = subscription.paymentDueOnAcceptance(now, Clock.System.now()) != null
when (route) {
is SubscriptionRoute.Review -> SubscriptionReview(
subscription = subscription,
payOnAcceptance = payOnAcceptance,
now = now,
contact = contacts.contactFor(subscription),
onDetails = {
Expand Down Expand Up @@ -746,19 +750,38 @@ private val SubscriptionRoute.id: PaykitSubscriptionId
is SubscriptionRoute.Cancel -> id
}

/** Keeps the Review & Subscribe layout on screen while the first payment of an accepted subscription is sent. */
@Composable
fun SubscriptionFirstPaymentProgress(
subscription: PaykitSubscription,
contact: PubkyProfile,
modifier: Modifier = Modifier,
) {
SubscriptionReview(
subscription = subscription,
contact = contact,
now = rememberSubscriptionNow(persistentListOf(subscription)),
onDetails = null,
onSubscribe = { true },
isPaying = true,
modifier = modifier
)
}

@Composable
private fun SubscriptionReview(
subscription: PaykitSubscription,
contact: PubkyProfile,
payOnAcceptance: Boolean,
now: Instant,
onDetails: () -> Unit,
onDetails: (() -> Unit)?,
onSubscribe: suspend () -> Boolean,
modifier: Modifier = Modifier,
isPaying: Boolean = false,
) {
var loading by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(isPaying) }
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.padding(horizontal = 16.dp)
Expand All @@ -769,18 +792,7 @@ private fun SubscriptionReview(
}
MoneyDisplay(sats = subscription.displaySats, showSymbol = true)
VerticalSpacer(24.dp)
SubscriptionProviderCard(subscription, contact, onClick = onDetails)
subscription.paymentDueOnAcceptance(now, Clock.System.now())?.billingPeriod?.let { period ->
VerticalSpacer(16.dp)
BodyS(
text = stringResource(
R.string.subscriptions__first_period_ends,
dateTimeFormatterOf("MMM d, yyyy, HH:mm")
.format(java.time.Instant.ofEpochMilli(period.endsAt.toEpochMilliseconds())),
),
color = Colors.White64,
)
}
SubscriptionProviderCard(subscription, contact, onClick = onDetails, showAsCard = true)
if (!subscription.recurrence.unit.isSupported) {
VerticalSpacer(16.dp)
BodyM(text = stringResource(R.string.subscriptions__unsupported_description), color = Colors.White64)
Expand All @@ -795,20 +807,23 @@ private fun SubscriptionReview(
Image(
painter = painterResource(R.drawable.subscription_clock),
contentDescription = null,
modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally)
modifier = Modifier
.size(256.dp)
.align(Alignment.CenterHorizontally)
.graphicsLayer {
scaleX = CLOCK_SCALE
scaleY = CLOCK_SCALE
rotationZ = CLOCK_ROTATION_DEGREES
translationX = CLOCK_OFFSET_X.toPx()
}
)
FillHeight()
if (subscription.isProposalActionable(now)) {
if (loading || subscription.isProposalActionable(now)) {
SwipeToConfirm(
text = stringResource(
if (payOnAcceptance) {
R.string.subscriptions__swipe_to_subscribe_and_pay
} else {
R.string.subscriptions__swipe_to_subscribe
}
),
text = stringResource(R.string.subscriptions__swipe_to_subscribe),
color = Colors.Brand,
loading = loading,
confirmed = isPaying,
onConfirm = {
loading = true
scope.launch {
Expand All @@ -827,16 +842,17 @@ private fun SubscriptionProviderCard(
contact: PubkyProfile,
subtitle: String? = null,
onClick: (() -> Unit)? = null,
showAsCard: Boolean = onClick != null,
) {
val displayedSubtitle = subtitle ?: subscription.subscriptionFrequencyText()
val cardModifier = if (onClick == null) {
val cardModifier = if (!showAsCard) {
Modifier.fillMaxWidth()
} else {
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Colors.Gray6)
.clickable(onClick = onClick)
.then(onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier)
.padding(16.dp)
}
Row(
Expand All @@ -857,7 +873,7 @@ private fun SubscriptionProviderCard(
overflow = TextOverflow.Ellipsis,
)
}
if (onClick != null) {
if (showAsCard) {
Icon(painterResource(R.drawable.ic_chevron_right), contentDescription = null, tint = Colors.White64)
}
}
Expand Down Expand Up @@ -1002,7 +1018,7 @@ private fun SubscriptionCancel(
}
}

private fun List<PubkyProfile>.contactFor(subscription: PaykitSubscription): PubkyProfile =
internal fun List<PubkyProfile>.contactFor(subscription: PaykitSubscription): PubkyProfile =
firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, subscription.counterparty) }
?: PubkyProfile.placeholder(subscription.counterparty)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ fun SendConfirmScreen(
onClickAddTag: () -> Unit,
onClickTag: (String) -> Unit,
onNavigateToPin: () -> Unit,
autoPayContent: (@Composable () -> Unit)? = null,
) {
val scope = rememberCoroutineScope()
var isLoading by rememberSaveable { mutableStateOf(false) }
Expand Down Expand Up @@ -188,6 +189,7 @@ fun SendConfirmScreen(
showBiometrics = showBiometrics,
canGoBack = canGoBack,
initialShowDetails = uiState.isInitialSubscriptionPayment && !uiState.shouldAutomaticallyPay,
autoPayContent = autoPayContent,
onBack = onBack,
onEvent = onEvent,
onClickAddTag = onClickAddTag,
Expand All @@ -212,6 +214,21 @@ fun SendConfirmScreen(
)
}

@Composable
private fun AutoPayOverlay(
isVisible: Boolean,
content: (@Composable () -> Unit)?,
) {
if (!isVisible || content == null) return
Box(
modifier = Modifier
.fillMaxSize()
.gradientBackground()
) {
content()
}
}

@Composable
@Suppress("CyclomaticComplexMethod")
internal fun SendConfirmContent(
Expand All @@ -224,6 +241,7 @@ internal fun SendConfirmContent(
preparingContact: PubkyProfile? = null,
canGoBack: Boolean = true,
initialShowDetails: Boolean = false,
autoPayContent: (@Composable () -> Unit)? = null,
onBack: () -> Unit = {},
onEvent: (SendEvent) -> Unit = {},
onClickAddTag: () -> Unit = {},
Expand Down Expand Up @@ -289,6 +307,8 @@ internal fun SendConfirmContent(
}
}

AutoPayOverlay(isVisible = !isPreparing && isAutomaticPaymentLoading, content = autoPayContent)

if (showBiometrics && !isPreparing) {
BiometricsView(
onSuccess = onBiometricsSuccess,
Expand Down
Loading
Loading