Skip to content
Draft
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
8 changes: 8 additions & 0 deletions platforms/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ val checkout = ShopifyCheckoutKit.present(checkoutUrl, activity) {
checkout?.dismiss()
```

Calling `CheckoutHandle.dismiss()` is caller-controlled teardown and does not invoke `onDismiss`.

## Embed checkout

Use `ShopifyCheckout` when your app owns the presentation container. The view owns the checkout header, close control,
Expand Down Expand Up @@ -499,6 +501,12 @@ Register checkout callbacks directly when presenting or creating a checkout. Sta
each provide a typed `Checkout` snapshot through `event.checkout`. Failures provide a `CheckoutException` through
`event.error`.

Use `onFail` for terminal checkout failures and `onDismiss` for presentation lifecycle. For a Checkout Kit-managed
sheet, `onDismiss` runs after the sheet closes, independently of checkout outcome. When a terminal failure closes the
sheet, `onFail` runs first and `onDismiss` follows; these callbacks are not mutually exclusive. `onComplete` reports
that the order completed; it does not report presentation closure, and `onDismiss` follows when the buyer later closes
checkout.

```kotlin
ShopifyCheckoutKit.present(checkoutUrl, activity) {
onStart { event ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ internal class CheckoutBottomSheet(
private var dismissNotified = false
private var dismissing = false
private var dismissFinalized = false
private var notifyDismissalOnFinalize = false

/**
* Invoked once when this sheet reaches its terminal dismissal state, before the dialog window
Expand All @@ -56,6 +57,7 @@ internal class CheckoutBottomSheet(
dismissNotified = false
dismissing = false
dismissFinalized = false
notifyDismissalOnFinalize = false

setContentView(R.layout.checkout_sheet_content)
val appearance = ShopifyCheckoutKit.configuration.appearance
Expand Down Expand Up @@ -147,14 +149,13 @@ internal class CheckoutBottomSheet(
}

/**
* Dismisses checkout in response to a buyer action, notifies the listener once, and uses the
* normal sheet animation.
* Dismisses checkout in response to a buyer action, then notifies the listener once the sheet
* has closed.
*/
private fun dismissedByBuyer() {
if (dismissing) return

notifyCheckoutDismissed()
dismiss(animate = true)
dismiss(animate = true, notifyOnFinalize = true)
}

/**
Expand All @@ -167,7 +168,14 @@ internal class CheckoutBottomSheet(
/**
* Dismisses the sheet, optionally skipping animation for lifecycle teardown.
*/
internal fun dismiss(animate: Boolean) {
internal fun dismiss(animate: Boolean, notifyOnFinalize: Boolean = false) {
if (notifyOnFinalize) {
notifyDismissalOnFinalize = true
if (dismissFinalized) {
notifyCheckoutDismissed()
}
}

val sheet = findViewById<CheckoutBottomSheetLayout>(R.id.checkoutKitSheet)
if (dismissing) {
if (!animate && !dismissFinalized) {
Expand Down Expand Up @@ -196,8 +204,8 @@ internal class CheckoutBottomSheet(
private fun dismissAfterSheetDismissAnimation() {
if (dismissing) return

notifyCheckoutDismissed()
dismissing = true
notifyDismissalOnFinalize = true
finishDismiss()
}

Expand All @@ -212,17 +220,20 @@ internal class CheckoutBottomSheet(
onDismissFinalized = null
destroyPresentedCheckoutView()
findViewById<CheckoutBottomSheetLayout>(R.id.checkoutKitSheet)?.onDismissRequested = null
if (!isShowing) return

try {
super.dismiss()
} catch (_: IllegalArgumentException) {
log.w(LOG_TAG, "Window was already detached before dismissal completed.")
if (isShowing) {
try {
super.dismiss()
} catch (_: IllegalArgumentException) {
log.w(LOG_TAG, "Window was already detached before dismissal completed.")
}
}
if (notifyDismissalOnFinalize) {
notifyCheckoutDismissed()
}
}

/**
* Sends the dismissal callback once across close button, back, outside touch, and gesture paths.
* Sends the dismissal callback once across all Checkout Kit-managed dismissal paths.
*/
private fun notifyCheckoutDismissed() {
if (!dismissNotified) {
Expand Down Expand Up @@ -262,7 +273,7 @@ internal class CheckoutBottomSheet(
internal fun closeCheckoutWithError(exception: CheckoutException) {
log.d(LOG_TAG, "Closing with error, calling onCheckoutFailed.")
checkoutListener.onCheckoutFailed(CheckoutFailureEvent(exception))
dismiss()
dismiss(animate = true, notifyOnFinalize = true)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ public interface CheckoutListener {
public fun onCheckoutFailed(event: CheckoutFailureEvent)

/**
* Event representing dismissal of checkout by the buyer.
* Called after a Checkout Kit presentation closes or when embedded checkout requests dismissal.
*
* Dismissal describes presentation lifecycle independently of checkout outcome. When a terminal
* failure closes a Checkout Kit presentation, [onCheckoutFailed] is called before this method.
*/
public fun onCheckoutDismissed()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ public class CheckoutPresentation internal constructor() {
}

/**
* Called when the buyer dismisses checkout.
* Called after a Checkout Kit presentation closes or when embedded checkout requests dismissal.
*
* Dismissal describes presentation lifecycle independently of checkout outcome. When a terminal
* failure closes a Checkout Kit presentation, the [onFail] handler runs before this handler.
*/
public fun onDismiss(handler: () -> Unit) {
onDismiss = handler
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.timeout
Expand Down Expand Up @@ -712,35 +711,112 @@ class CheckoutBottomSheetTest {
}

@Test
fun `calls onCheckoutDismissed if cancel is called`() {
val mockListener = mock<DefaultCheckoutListener>()
val sheet = presentBottomSheet(checkoutListener = mockListener)
fun `cancel notifies dismissal after the bottom sheet closes`() {
var sheetWasShowingWhenDismissed: Boolean? = null
lateinit var sheet: CheckoutBottomSheet
val checkoutListener = object : DefaultCheckoutListener() {
override fun onCheckoutFailed(event: CheckoutFailureEvent) = Unit

override fun onCheckoutDismissed() {
sheetWasShowingWhenDismissed = sheet.isShowing
}
}
sheet = presentBottomSheet(checkoutListener = checkoutListener)
sheet.findViewById<CheckoutBottomSheetLayout>(R.id.checkoutKitSheet)!!
.layout(0, 0, TEST_SHEET_SIZE, TEST_SHEET_SIZE)

sheet.cancel()
shadowOf(Looper.getMainLooper()).idle()

verify(mockListener).onCheckoutDismissed()
verify(mockListener, never()).onCheckoutFailed(any())
assertThat(sheetWasShowingWhenDismissed).isNull()
assertThat(sheet.isShowing).isTrue()

runDismissAnimation()

assertThat(sheetWasShowingWhenDismissed).isFalse()
assertThat(sheet.isShowing).isFalse()
}

@Test
fun `closeCheckoutWithError invokes onCheckoutFailed and dismisses the bottom sheet`() {
val mockListener = mock<DefaultCheckoutListener>()
val checkoutSheet = presentBottomSheet(checkoutListener = mockListener)
fun `gesture dismissal notifies after the bottom sheet closes`() {
var sheetWasShowingWhenDismissed: Boolean? = null
lateinit var sheet: CheckoutBottomSheet
val checkoutListener = object : DefaultCheckoutListener() {
override fun onCheckoutFailed(event: CheckoutFailureEvent) = Unit

override fun onCheckoutDismissed() {
sheetWasShowingWhenDismissed = sheet.isShowing
}
}
sheet = presentBottomSheet(checkoutListener = checkoutListener)

val error = checkoutException()
sheet.findViewById<CheckoutBottomSheetLayout>(R.id.checkoutKitSheet)!!.onDismissRequested?.invoke()

checkoutSheet.closeCheckoutWithError(error)
shadowOf(Looper.getMainLooper()).idle()
assertThat(sheetWasShowingWhenDismissed).isFalse()
assertThat(sheet.isShowing).isFalse()
}

@Test
fun `programmatic dismissal does not notify checkout dismissal`() {
val mockListener = mock<DefaultCheckoutListener>()
val sheet = presentBottomSheet(checkoutListener = mockListener)

sheet.dismiss()
runDismissAnimation()

verify(mockListener, never()).onCheckoutDismissed()
val captor = argumentCaptor<CheckoutFailureEvent>()
verify(mockListener).onCheckoutFailed(captor.capture())
assertThat(captor.firstValue.error).isSameAs(error)
}

@Test
fun `closeCheckoutWithError invokes failure then dismissal after closing the bottom sheet`() {
val lifecycleEvents = mutableListOf<String>()
var sheetWasShowingWhenDismissed: Boolean? = null
lateinit var checkoutSheet: CheckoutBottomSheet
val checkoutListener = object : DefaultCheckoutListener() {
override fun onCheckoutFailed(event: CheckoutFailureEvent) {
lifecycleEvents += "fail"
}

override fun onCheckoutDismissed() {
lifecycleEvents += "dismiss"
sheetWasShowingWhenDismissed = checkoutSheet.isShowing
}
}
checkoutSheet = presentBottomSheet(checkoutListener = checkoutListener)

checkoutSheet.closeCheckoutWithError(checkoutException())
runDismissAnimation()

assertThat(lifecycleEvents).isEqualTo(listOf("fail", "dismiss"))
assertThat(sheetWasShowingWhenDismissed).isFalse()
assertThat(checkoutSheet.isShowing).isFalse()
}

@Test
fun `buyer dismissal followed by failure notifies failure before one finalized dismissal`() {
val lifecycleEvents = mutableListOf<String>()
val checkoutListener = object : DefaultCheckoutListener() {
override fun onCheckoutFailed(event: CheckoutFailureEvent) {
lifecycleEvents += "fail"
}

override fun onCheckoutDismissed() {
lifecycleEvents += "dismiss"
}
}
val checkoutSheet = presentBottomSheet(checkoutListener = checkoutListener)
checkoutSheet.findViewById<CheckoutBottomSheetLayout>(R.id.checkoutKitSheet)!!
.layout(0, 0, TEST_SHEET_SIZE, TEST_SHEET_SIZE)

checkoutSheet.cancel()
checkoutSheet.closeCheckoutWithError(checkoutException())

assertThat(lifecycleEvents).containsExactly("fail")

runDismissAnimation()

assertThat(lifecycleEvents).containsExactly("fail", "dismiss")
}

@Test
fun `calls onCheckoutDismissed if close menu item is clicked`() {
val mockListener = mock<DefaultCheckoutListener>()
Expand Down
13 changes: 10 additions & 3 deletions platforms/swift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ final class CartViewController: UIViewController, CheckoutDelegate {
}

func checkoutDidDismiss() {
// The buyer dismissed checkout.
// The checkout presentation closed.
}

func checkoutDidFail(_ event: CheckoutFailureEvent) {
Expand Down Expand Up @@ -322,9 +322,16 @@ the current `ShopifyCheckoutKit.Checkout` snapshot. Callbacks run on the main ac
| Checkout is visible and ready for interaction. | `checkoutDidStart(_:)` | `.onStart` |
| Checkout totals, line items, fulfillment, or messages change. | `checkoutDidUpdate(_:)` | `.onUpdate` |
| Checkout completes. | `checkoutDidComplete(_:)` | `.onComplete` |
| The buyer dismisses checkout. | `checkoutDidDismiss()` | `.onDismiss` |
| The checkout presentation closes. | `checkoutDidDismiss()` | `.onDismiss` |
| Checkout cannot continue. | `checkoutDidFail(_:)` | `.onFail` |

These callbacks are not mutually exclusive. When a terminal failure closes a presented checkout,
`checkoutDidFail(_:)` fires first and `checkoutDidDismiss()` follows after the presentation
closes. Calling `dismiss(animated:)` on the returned view controller is caller-controlled teardown
and does not invoke either callback. `checkoutDidComplete(_:)` reports that the order completed; it
does not report presentation closure, and `checkoutDidDismiss()` follows when the buyer later closes
checkout.

UIKit delegates must implement `checkoutDidDismiss()` and `checkoutDidFail(_:)`.
The other lifecycle methods have default implementations. When migrating from `checkoutDidFail(error:)`, implement
`checkoutDidFail(_ event: CheckoutFailureEvent)` and read the error from `event.error`.
Expand Down Expand Up @@ -564,7 +571,7 @@ AcceleratedCheckoutButtons(cartID: cartID)
// Handle checkout failure.
}
.onDismiss {
// The buyer dismissed the accelerated checkout flow.
// The accelerated checkout presentation closed.
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,10 @@ extension AcceleratedCheckoutButtons {
return newView
}

/// Adds an action to perform when the buyer dismisses the checkout experience.
/// Adds an action to perform after the accelerated checkout presentation closes.
///
/// Dismissal describes presentation lifecycle independently of checkout outcome. When a
/// terminal failure closes checkout, the `onFail` action runs before this action.
///
/// Use this modifier to handle checkout dismissal:
///
Expand All @@ -219,7 +222,7 @@ extension AcceleratedCheckoutButtons {
/// }
/// ```
///
/// - Parameter action: The action to perform when the buyer dismisses checkout
/// - Parameter action: The action to perform after the checkout presentation closes
/// - Returns: A view with the checkout dismissal handler set
public func onDismiss(_ action: @escaping () -> Void) -> AcceleratedCheckoutButtons {
var newView = self
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ class ApplePayViewController: WalletController, PayController {
@MainActor
public var onCheckoutFail: ((CheckoutError) -> Void)?

/// Callback invoked when the buyer dismisses the checkout experience.
/// This closure is called on the main thread when the user dismisses the checkout.
/// Callback invoked when the active Apple Pay or Checkout Kit presentation closes.
/// This closure is called on the main thread independently of checkout outcome. When a
/// terminal failure closes Checkout Kit, ``onCheckoutFail`` is invoked before this closure.
///
/// Example usage:
/// ```swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public struct EventHandlers {
public var checkoutDidComplete: ((CheckoutCompleteEvent) -> Void)?
public var checkoutAction: ((CheckoutLink) -> CheckoutLinkAction)?
public var checkoutDidFail: ((CheckoutError) -> Void)?

/// Called after the accelerated checkout presentation closes, independently of checkout outcome.
public var checkoutDidDismiss: (() -> Void)?
public var renderStateDidChange: ((RenderState) -> Void)?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ public protocol CheckoutDelegate: AnyObject {
/// Asks the delegate how to handle a link clicked in checkout.
func checkoutAction(for link: CheckoutLink) -> CheckoutLinkAction

/// Tells the delegate that the buyer dismissed checkout.
/// Tells the delegate after the presented checkout closes.
///
/// Dismissal describes presentation lifecycle independently of checkout outcome. When a
/// terminal failure closes checkout, ``checkoutDidFail(_:)`` is called before this method.
func checkoutDidDismiss()

/// Tells the delegate that checkout cannot continue.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab
return copy
}

/// Registers a handler called after the presented checkout closes.
///
/// Dismissal describes presentation lifecycle independently of checkout outcome. When a
/// terminal failure closes checkout, the `onFail` handler runs before this handler.
@discardableResult public func onDismiss(_ action: @escaping () -> Void) -> Self {
var copy = self
copy.onDismissAction = action
Expand Down
Loading
Loading