Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
96becc3
fix: pay first onchain subscription period in one swipe
jvsena42 Oct 5, 2026
bc2595a
docs: update subscribe journey for single swipe
jvsena42 Oct 5, 2026
fa90d60
chore: add subscription single swipe changelog fragment
jvsena42 Oct 5, 2026
ca5047c
fix: keep review layout while first subscription payment sends
jvsena42 Oct 5, 2026
ff6893c
docs: describe review layout during first subscription payment
jvsena42 Oct 5, 2026
1a1068f
fix: drop first billing period text from subscription review
jvsena42 Oct 5, 2026
e6394d9
fix: label subscription review swipe as swipe to subscribe
jvsena42 Oct 6, 2026
6d17081
fix: draw subscription review clock at figma scale and rotation
jvsena42 Oct 6, 2026
f75f6c3
docs: drop ios difference note from subscribe journey
jvsena42 Oct 6, 2026
7612cf9
chore: name subscription changelog fragment after pr
jvsena42 Oct 8, 2026
c3b2944
docs: keep subscription journeys valid with single swipe
jvsena42 Oct 8, 2026
3db6846
fix: keep first payment overlay inert and its swipe settled
jvsena42 Oct 8, 2026
6e6a7fd
refactor: reuse contacts and subscription lookup in send sheet
jvsena42 Oct 8, 2026
23d21c7
test: cover first payment overlay and fee warning
jvsena42 Oct 8, 2026
ab5ae18
fix: check first payment fee against preselected coins
jvsena42 Oct 12, 2026
e7b424e
fix: keep manual confirmation after choosing coins for first payment
jvsena42 Oct 12, 2026
37bcebc
fix: check first payment fee ratio without a usd rate
jvsena42 Oct 12, 2026
f729fab
docs: keep sheet handoff check in subscribe journey
jvsena42 Oct 12, 2026
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 @@ -181,13 +182,18 @@ fun SendConfirmScreen(
currentOnEvent(SendEvent.StartInitialSubscriptionPayment)
}

LaunchedEffect(uiState.initialSubscriptionPaymentNeedsConfirmation) {
if (uiState.initialSubscriptionPaymentNeedsConfirmation) isLoading = false
}

SendConfirmContent(
uiState = uiState,
isNodeRunning = isNodeRunning,
isLoading = isLoading,
showBiometrics = showBiometrics,
canGoBack = canGoBack,
initialShowDetails = uiState.isInitialSubscriptionPayment && !uiState.shouldAutomaticallyPay,
autoPayContent = autoPayContent,
onBack = onBack,
onEvent = onEvent,
onClickAddTag = onClickAddTag,
Expand All @@ -212,6 +218,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 +245,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 +311,8 @@ internal fun SendConfirmContent(
}
}

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

if (showBiometrics && !isPreparing) {
BiometricsView(
onSuccess = onBiometricsSuccess,
Expand Down Expand Up @@ -333,7 +357,7 @@ private fun ContentRunning(
onClickTag: (String) -> Unit = {},
onSwipeToConfirm: () -> Unit = {},
) {
var showDetails by rememberSaveable { mutableStateOf(initialShowDetails) }
var showDetails by rememberSaveable(initialShowDetails) { mutableStateOf(initialShowDetails) }
val swipeProgress = remember { mutableFloatStateOf(0f) }
val isPreparing = preparingRequest != null
val isLnurlPay = !isPreparing && uiState.lnurl is LnurlParams.LnurlPay
Expand Down
Loading
Loading