diff --git a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt index bc8fb87289..d22290835e 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt @@ -6,6 +6,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -24,7 +27,9 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Before import org.junit.Rule import org.junit.Test +import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.AppThemeSurface import kotlin.test.assertEquals @@ -127,6 +132,54 @@ class SheetHostTest { assertEquals(2, presentationCount) } + @Test + fun subscriptionPaymentReplacementKeepsSheetExpanded() { + val sheet = mutableStateOf( + Sheet.Subscription(SubscriptionRoute.Review(PaykitSubscriptionId("subscription", "payee"))) + ) + var dismissCount = 0 + var visibleCount = 0 + lateinit var sheetState: SheetState + composeTestRule.setContent { + AppThemeSurface { + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + SheetHost( + shouldExpand = true, + visibilityKey = sheet.value, + onDismiss = { dismissCount++ }, + onVisible = { visibleCount++ }, + sheetState = sheetState, + sheets = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(320.dp) + .testTag(if (sheet.value is Sheet.Send) "SubscriptionPayment" else "SubscriptionReview") + ) + }, + content = { Box(Modifier.fillMaxSize()) }, + ) + } + } + composeTestRule.onNodeWithTag("SubscriptionReview").assertIsDisplayed() + val expandedOffset = composeTestRule.runOnIdle { sheetState.requireOffset() } + composeTestRule.mainClock.autoAdvance = false + composeTestRule.runOnIdle { sheet.value = Sheet.Send(SendRoute.Confirm) } + + repeat(30) { + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.runOnIdle { + assertEquals(SheetValue.Expanded, sheetState.currentValue) + assertEquals(SheetValue.Expanded, sheetState.targetValue) + assertEquals(expandedOffset, sheetState.requireOffset()) + assertEquals(0, dismissCount) + } + } + + composeTestRule.onNodeWithTag("SubscriptionPayment").assertIsDisplayed() + composeTestRule.runOnIdle { assertEquals(2, visibleCount) } + } + @Test fun programmaticHideDoesNotInvokeDismissalCallback() { val shouldExpand = mutableStateOf(true) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fa01bde8ba..0b8b97535d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5823,7 +5823,7 @@ class AppViewModel @Inject constructor( val previousJob = sheetTransitionJob val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { receiveSheetContext = null - _currentSheet.value?.let { + if (_currentSheet.value != null && !replacesSubscriptionReview(sheetType)) { _currentSheet.update { null } delay(SCREEN_TRANSITION_DELAY) } @@ -5846,6 +5846,13 @@ class AppViewModel @Inject constructor( nextJob.start() } + private fun replacesSubscriptionReview(sheet: Sheet): Boolean { + if (sheet !is Sheet.Send || !_sendUiState.value.isInitialSubscriptionPayment) return false + val review = (_currentSheet.value as? Sheet.Subscription)?.route as? SubscriptionRoute.Review ?: return false + val requestId = _sendUiState.value.incomingPaymentRequestId ?: return false + return review.id == PaykitSubscriptionId(requestId.paymentRequestId, requestId.counterparty) + } + fun hideSheet() = hideSheet(shouldFlushDeferredScan = true) private fun hideSheet(shouldFlushDeferredScan: Boolean) { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0f2f0e8796..30942c1b8d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9308,6 +9308,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `subscription acceptance pays a due period materialized during acceptance`() = test { val subscription = subscriptionStartingAt(Clock.System.now() + 60.seconds) val dueRequest = paymentRequest().copy( + paymentRequestId = subscription.paymentRequestId, lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, billingPeriod = PaykitBillingPeriod( startsAt = Clock.System.now(), @@ -9323,10 +9324,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) advanceUntilIdle() - val startedPayment = sut.acceptSubscriptionAndStartPayment(subscription).getOrThrow() - advanceUntilIdle() - - assertTrue(startedPayment) + sut.currentSheet.test { + assertTrue(awaitItem() is Sheet.Subscription) + assertTrue(sut.acceptSubscriptionAndStartPayment(subscription).getOrThrow()) + advanceUntilIdle() + assertTrue((awaitItem() as Sheet.Send).route is SendRoute.Error) + expectNoEvents() + } verify(privatePaykitRepo).beginPaymentRequestWaitingForUpdatedList(dueRequest) assertTrue(sut.currentSheet.value is Sheet.Send) assertTrue(sut.sendUiState.value.isInitialSubscriptionPayment) @@ -9335,19 +9339,71 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `initial subscription send dismisses review before presentation`() = test { + fun `initial subscription send replaces the matching review without dismissal`() = test { val subscription = subscriptionStartingAt(Clock.System.now()) val destination = Sheet.Send(SendRoute.Confirm) - sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) + val review = Sheet.Subscription(SubscriptionRoute.Review(subscription.id)) + sut.showSheet(review) advanceUntilIdle() - setSendState(SendUiState(isInitialSubscriptionPayment = true)) + setSendState( + SendUiState( + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = PaykitPaymentRequestId( + subscription.paymentRequestId, + subscription.counterparty, + ), + ) + ) - sut.showSheet(destination) - runCurrent() + sut.currentSheet.test { + assertEquals(review, awaitItem()) + sut.showSheet(destination) + runCurrent() - assertNull(sut.currentSheet.value) - advanceUntilIdle() - assertEquals(destination, sut.currentSheet.value) + assertEquals(destination, awaitItem()) + advanceUntilIdle() + expectNoEvents() + } + } + + @Test + fun `other subscription sheet transitions still dismiss before presentation`() = test { + val subscription = subscriptionStartingAt(Clock.System.now()) + val review = Sheet.Subscription(SubscriptionRoute.Review(subscription.id)) + val destination = Sheet.Send(SendRoute.Confirm) + val initialState = SendUiState( + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = PaykitPaymentRequestId(subscription.paymentRequestId, subscription.counterparty), + ) + val otherReview = Sheet.Subscription( + SubscriptionRoute.Review(subscription.id.copy(paymentRequestId = "other-subscription")) + ) + val otherCounterparty = Sheet.Subscription( + SubscriptionRoute.Review(subscription.id.copy(counterparty = "other-counterparty")) + ) + val details = Sheet.Subscription(SubscriptionRoute.Details(subscription.id)) + listOf( + Triple(review, initialState.copy(isInitialSubscriptionPayment = false), destination), + Triple(otherReview, initialState, destination), + Triple(otherCounterparty, initialState, destination), + Triple(details, initialState, destination), + Triple(review, initialState, Sheet.Receive()), + ).forEach { (source, state, target) -> + sut.showSheet(source) + advanceUntilIdle() + setSendState(state) + + sut.currentSheet.test { + assertEquals(source, awaitItem()) + sut.showSheet(target) + runCurrent() + + assertNull(awaitItem()) + advanceUntilIdle() + assertEquals(target, awaitItem()) + expectNoEvents() + } + } } @Test diff --git a/changelog.d/next/1469.fixed.md b/changelog.d/next/1469.fixed.md new file mode 100644 index 0000000000..7bb40e2bef --- /dev/null +++ b/changelog.d/next/1469.fixed.md @@ -0,0 +1 @@ +Kept the subscription review sheet open when starting the first payment. diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 6c7a50352e..1132b667b5 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -15,7 +15,8 @@ Verify the Review and Subscribe sheet appears with 5,000 sats and the creator contact card Verify the swipe control reads "Swipe To Subscribe & Pay" when the first period is due on acceptance, otherwise "Swipe To Subscribe" Swipe the confirm control to the end - If the first period was due on acceptance: complete the send flow that opens, then verify the Subscribed confirmation appears + If the first period was due on acceptance: verify the sheet stays expanded as Review and Subscribe changes to the send flow, without closing, exposing Home or the subscriptions list, and sliding back in + If the first period was due on acceptance: complete the send flow inside the same sheet, then verify the Subscribed confirmation appears If no payment was due: verify the Subscribed confirmation appears directly, with no send flow Tap Close on the Subscribed confirmation If a payment was made: verify its billing period is no longer offered with Pay or Dismiss, no Payment Requests sheet opens for it, and the home payment-request bell is absent when no other requests are pending