From 1d569b0112f69a7ace14e60e050af75b5868c91e Mon Sep 17 00:00:00 2001 From: Saurabh Pandey Date: Tue, 20 Oct 2026 02:48:51 +0530 Subject: [PATCH 1/3] Add the PayPalSavedPaymentMethodView checkout component The public view, skeleton row and shimmer, edit loader, Learn more lander and LoadingSpinner asset, with render tests. Rebased onto view-edit-fi-beta-feature after #1887 merged. --- Braintree.xcodeproj/project.pbxproj | 16 + .../EditFIStyleGuard.swift | 13 + .../PayPalCreditMessagingLanderView.swift | 42 +++ .../PayPalSavedPaymentMethodSkeletonRow.swift | 77 ++++ .../PayPalSavedPaymentMethodView.swift | 336 ++++++++++++++++++ .../PayPalSavedPaymentMethodViewModel.swift | 37 +- .../LoadingSpinner.imageset/Contents.json | 16 + .../LoadingSpinner.pdf | Bin 0 -> 5977 bytes ...PalSavedPaymentMethodViewModel_Tests.swift | 34 +- ...alSavedPaymentMethodView_RenderTests.swift | 195 ++++++++++ 10 files changed, 739 insertions(+), 27 deletions(-) create mode 100644 Sources/BraintreePayPalSavedPaymentMethod/PayPalCreditMessagingLanderView.swift create mode 100644 Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodSkeletonRow.swift create mode 100644 Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift create mode 100644 Sources/BraintreePayPalSavedPaymentMethod/Resources/Assets.xcassets/LoadingSpinner.imageset/Contents.json create mode 100644 Sources/BraintreePayPalSavedPaymentMethod/Resources/Assets.xcassets/LoadingSpinner.imageset/LoadingSpinner.pdf create mode 100644 UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodView_RenderTests.swift diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index fe271cf22..2c5ef12da 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -336,6 +336,10 @@ BE2F98D028A2BCCD008EF189 /* BTConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2F98CF28A2BCCD008EF189 /* BTConfiguration.swift */; }; BE32ACBC2907744400A61FED /* BTCardNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE32ACBB2907744400A61FED /* BTCardNetwork.swift */; }; BE349111294B77E100D2CF68 /* BTPayPalVaultRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE349110294B77E100D2CF68 /* BTPayPalVaultRequest.swift */; }; + F8512A85BB80A0C30548EBA7 /* PayPalSavedPaymentMethodView_RenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B86B9D7895CB2D004DD4D45 /* PayPalSavedPaymentMethodView_RenderTests.swift */; }; + 84D815FE3026E88FC3037895 /* PayPalSavedPaymentMethodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B52AE84A91F5609E2B73E0 /* PayPalSavedPaymentMethodView.swift */; }; + D04BC3F076EB65C0195500A8 /* PayPalCreditMessagingLanderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EBAC2950CE2C6AED55617D1 /* PayPalCreditMessagingLanderView.swift */; }; + 4F73E0FC437630FD8018D5E5 /* PayPalSavedPaymentMethodSkeletonRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0379D3235C0E03E37B53A3F /* PayPalSavedPaymentMethodSkeletonRow.swift */; }; BE349113294B798300D2CF68 /* BTPayPalRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE349112294B798300D2CF68 /* BTPayPalRequest.swift */; }; BE48CE4829D5DDA600F0825C /* BTThreeDSecureV2TextBoxCustomization.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE82E74029C4A1330059FE97 /* BTThreeDSecureV2TextBoxCustomization.swift */; }; BE549F112BF5445F00B6F441 /* BTPayPalReturnURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6BC22D2BA9CFFC00C3E321 /* BTPayPalReturnURL.swift */; }; @@ -1242,6 +1246,10 @@ BE2F98CF28A2BCCD008EF189 /* BTConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTConfiguration.swift; sourceTree = ""; }; BE32ACBB2907744400A61FED /* BTCardNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTCardNetwork.swift; sourceTree = ""; }; BE349110294B77E100D2CF68 /* BTPayPalVaultRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalVaultRequest.swift; sourceTree = ""; }; + 2B86B9D7895CB2D004DD4D45 /* PayPalSavedPaymentMethodView_RenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalSavedPaymentMethodView_RenderTests.swift; sourceTree = ""; }; + 80B52AE84A91F5609E2B73E0 /* PayPalSavedPaymentMethodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalSavedPaymentMethodView.swift; sourceTree = ""; }; + 7EBAC2950CE2C6AED55617D1 /* PayPalCreditMessagingLanderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalCreditMessagingLanderView.swift; sourceTree = ""; }; + D0379D3235C0E03E37B53A3F /* PayPalSavedPaymentMethodSkeletonRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalSavedPaymentMethodSkeletonRow.swift; sourceTree = ""; }; BE349112294B798300D2CF68 /* BTPayPalRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalRequest.swift; sourceTree = ""; }; BE549F132BF6576300B6F441 /* BTAnalyticsEventsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTAnalyticsEventsStorage.swift; sourceTree = ""; }; BE54C0322912B68E009C6CEE /* BTHTTP_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BTHTTP_Tests.swift; sourceTree = ""; }; @@ -2152,6 +2160,7 @@ FE0100000000000000000011 /* PayPalSavedPaymentMethodSummary_Tests.swift */, FE0100000000000000000092 /* EditFIStyleGuard_Tests.swift */, FE0100000000000000000094 /* PayPalSavedPaymentMethodFont_Tests.swift */, + 2B86B9D7895CB2D004DD4D45 /* PayPalSavedPaymentMethodView_RenderTests.swift */, FE01000000000000000000A0 /* PayPalSavedPaymentMethodViewStyle_Tests.swift */, FE01000000000000000000A1 /* MockPayPalClient.swift */, FE0100000000000000000017 /* PayPalCreditMessaging_Tests.swift */, @@ -2649,6 +2658,9 @@ E4EAA7FE2AB18C9D955E8A23 /* BraintreePayPalSavedPaymentMethod */ = { isa = PBXGroup; children = ( + D0379D3235C0E03E37B53A3F /* PayPalSavedPaymentMethodSkeletonRow.swift */, + 7EBAC2950CE2C6AED55617D1 /* PayPalCreditMessagingLanderView.swift */, + 80B52AE84A91F5609E2B73E0 /* PayPalSavedPaymentMethodView.swift */, FE0300000000000000000001 /* Models */, FE0100000000000000000009 /* PayPalSavedPaymentMethodClient.swift */, FE0100000000000000000007 /* PayPalSavedPaymentMethodError.swift */, @@ -3978,6 +3990,9 @@ FE0200000000000000000005 /* PayPalSavedPaymentMethodSummary.swift in Sources */, FE0200000000000000000002 /* PayPalSavedPaymentMethodType.swift in Sources */, FE0200000000000000000003 /* PayPalPayer.swift in Sources */, + 4F73E0FC437630FD8018D5E5 /* PayPalSavedPaymentMethodSkeletonRow.swift in Sources */, + D04BC3F076EB65C0195500A8 /* PayPalCreditMessagingLanderView.swift in Sources */, + 84D815FE3026E88FC3037895 /* PayPalSavedPaymentMethodView.swift in Sources */, FE0200000000000000000016 /* PayPalCreditMessagingPOSTBody.swift in Sources */, C29B5D0B52FEB8B1D3C12C52 /* CreditMessageContent.swift in Sources */, 917D66AD9EE2BFBF9A34237A /* PayPalSavedPaymentMethodViewModel.swift in Sources */, @@ -4003,6 +4018,7 @@ FE0200000000000000000011 /* PayPalSavedPaymentMethodSummary_Tests.swift in Sources */, FE0200000000000000000092 /* EditFIStyleGuard_Tests.swift in Sources */, FE0200000000000000000094 /* PayPalSavedPaymentMethodFont_Tests.swift in Sources */, + F8512A85BB80A0C30548EBA7 /* PayPalSavedPaymentMethodView_RenderTests.swift in Sources */, FE02000000000000000000A0 /* PayPalSavedPaymentMethodViewStyle_Tests.swift in Sources */, FE02000000000000000000A1 /* MockPayPalClient.swift in Sources */, FE0200000000000000000017 /* PayPalCreditMessaging_Tests.swift in Sources */, diff --git a/Sources/BraintreePayPalSavedPaymentMethod/EditFIStyleGuard.swift b/Sources/BraintreePayPalSavedPaymentMethod/EditFIStyleGuard.swift index cf7ab5e4b..a1a655a69 100644 --- a/Sources/BraintreePayPalSavedPaymentMethod/EditFIStyleGuard.swift +++ b/Sources/BraintreePayPalSavedPaymentMethod/EditFIStyleGuard.swift @@ -50,6 +50,19 @@ enum EditFIStyleDefaultConstants { static let stackedLayoutSpacing: CGFloat = 6 static let creditMessageFontSize: CGFloat = 16 + + /// Gap between the FI row and the credit-messaging line; tighter while the skeleton shows. + static let rowSpacing: CGFloat = 6 + static let loadingRowSpacing: CGFloat = 4 + + static let shimmerBarHeight: CGFloat = 16 + static let shimmerBarCornerRadius: CGFloat = 4 + /// Space left after the shimmer bar so it stops short of the trailing edge. + static let shimmerBarTrailingGap: CGFloat = 40 + + static let editLoaderOverlayOpacity: Double = 0.9 + static let editLoaderSpinnerSide: CGFloat = 48 + static let editLoaderSpinnerColor = UIColor.black } /// Resolves `PayPalSavedPaymentMethodViewStyle` values for rendering. diff --git a/Sources/BraintreePayPalSavedPaymentMethod/PayPalCreditMessagingLanderView.swift b/Sources/BraintreePayPalSavedPaymentMethod/PayPalCreditMessagingLanderView.swift new file mode 100644 index 000000000..7e3332204 --- /dev/null +++ b/Sources/BraintreePayPalSavedPaymentMethod/PayPalCreditMessagingLanderView.swift @@ -0,0 +1,42 @@ +import SwiftUI +import SafariServices + +/// Presents the credit-messaging "Learn more" lander (`click_url`) in an +/// `SFSafariViewController`, giving buyers Safari's built-in security and reader chrome +/// without pulling in a new dependency or triggering an app-switch consent alert. +struct PayPalCreditMessagingLanderView: UIViewControllerRepresentable { + + let url: URL + + /// Called when the buyer taps Safari's "Done" button, so the presenter can clear its state. + let onFinish: () -> Void + + func makeUIViewController(context: Context) -> SFSafariViewController { + let controller = SFSafariViewController(url: url) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ controller: SFSafariViewController, context: Context) { + context.coordinator.onFinish = onFinish + } + + func makeCoordinator() -> Coordinator { + Coordinator(onFinish: onFinish) + } + + // MARK: - Coordinator + + final class Coordinator: NSObject, SFSafariViewControllerDelegate { + + var onFinish: () -> Void + + init(onFinish: @escaping () -> Void) { + self.onFinish = onFinish + } + + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + onFinish() + } + } +} diff --git a/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodSkeletonRow.swift b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodSkeletonRow.swift new file mode 100644 index 000000000..d1235d779 --- /dev/null +++ b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodSkeletonRow.swift @@ -0,0 +1,77 @@ +import SwiftUI + +/// A left-to-right shimmer sweep, masked to the content's silhouette. Used for the skeleton loading state. +struct ShimmerModifier: ViewModifier { + + @State private var animating = false + + func body(content: Content) -> some View { + content + .overlay( + GeometryReader { geo in + LinearGradient( + gradient: Gradient(colors: [.clear, Color.white.opacity(0.65), .clear]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: geo.size.width * 0.6) + .offset(x: animating ? geo.size.width : -geo.size.width * 0.6) + } + ) + .mask(content) + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + animating = true + } + } + } +} + +extension View { + + /// Applies an animated shimmer sweep, masked to this view. Use on placeholder shapes. + func shimmering() -> some View { + modifier(ShimmerModifier()) + } +} + +/// A rounded shimmer placeholder bar that fills the available width and stops short of the trailing edge. +struct ShimmerBar: View { + + var body: some View { + RoundedRectangle(cornerRadius: EditFIStyleDefaultConstants.shimmerBarCornerRadius) + .fill(Color(.systemGray5)) + .frame(height: EditFIStyleDefaultConstants.shimmerBarHeight) + .frame(maxWidth: .infinity) + .padding(.trailing, EditFIStyleDefaultConstants.shimmerBarTrailingGap) + .shimmering() + } +} + +/// Loading placeholder for the FI row: the real PayPal brand mark stays visible while a +/// shimmer bar fills the space where the FI pill will appear. +struct PayPalSavedPaymentMethodSkeletonRow: View { + + let style: PayPalSavedPaymentMethodViewStyle + + var body: some View { + HStack(spacing: EditFIStyleGuard.dimension( + style.container?.fundingInstrument?.leadingGap, + default: EditFIStyleDefaultConstants.fundingInstrumentLeadingGap + )) { + PayPalBrandCluster(style: style) + ShimmerBar() + } + .accessibilityElement() + .accessibilityLabel("Loading saved payment method") + } +} + +/// Loading placeholder for the credit-messaging line: a shimmer bar that stops short of the edge. +struct CreditMessageSkeleton: View { + + var body: some View { + ShimmerBar() + .accessibilityHidden(true) + } +} diff --git a/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift new file mode 100644 index 000000000..1557551ef --- /dev/null +++ b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift @@ -0,0 +1,336 @@ +import SwiftUI + +#if canImport(BraintreeCore) +import BraintreeCore +#endif + +#if canImport(BraintreePayPal) +import BraintreePayPal +#endif + +/// A drop-in checkout component that shows the returning PayPal buyer's saved funding +/// instrument (FI) and lets them edit it, with optional inline Pay Later messaging. +/// +/// The component resolves and renders the FI on the buyer's default billing agreement, exposes +/// an edit affordance that launches the PayPal paysheet via `BTPayPalClient`, and reports the +/// tokenization outcome via `completion`. The buyer's FI is resolved by the SDK from the client +/// token — the merchant supplies the checkout request plus a `PayPalSavedPaymentMethodRequest` +/// carrying the amount, currency, and merchant account the component needs. +/// - Warning: This feature is in beta. It's public API may change or be removed in future releases. +public struct PayPalSavedPaymentMethodView: View { + + // MARK: - Private Properties + + @StateObject private var viewModel: PayPalSavedPaymentMethodViewModel + + /// Held on the view rather than the view model: `@StateObject` builds the view model once, so + /// anything stored there would keep the values from the first render. + private let payPalCheckoutRequest: BTPayPalCheckoutRequest + private let request: PayPalSavedPaymentMethodRequest + private let style: PayPalSavedPaymentMethodViewStyle + + // MARK: - Initializer + + /// Creates a `PayPalSavedPaymentMethodView`. + /// - Parameters: + /// - authorization: Required. A client token generated with the buyer's payment method ID. + /// A tokenization key cannot be used — it carries no `paymentMethodIdJwt`, so the saved + /// funding instrument cannot be resolved. + /// - universalLink: Required. The URL to use for the PayPal app switch flow. Must be a valid + /// HTTPS URL dedicated to Braintree app switch returns, allow-listed in your Control Panel. + /// - payPalCheckoutRequest: Required. The PayPal checkout request used for the edit tokenization. + /// - request: Required. The amount, currency, and merchant account used to resolve the saved + /// funding instrument and its Pay Later message. + /// - fallbackURLScheme: Optional. A custom URL scheme to use as a fallback if the universal link fails. + /// - style: Optional. Styling overrides. Defaults to the shipped `PayPalSavedPaymentMethodViewStyle`. + /// - completion: Called with the `BTPayPalAccountNonce` (or `Error`) when the edit tokenization completes. + public init( + authorization: String, + universalLink: URL, + payPalCheckoutRequest: BTPayPalCheckoutRequest, + request: PayPalSavedPaymentMethodRequest, + fallbackURLScheme: String? = nil, + style: PayPalSavedPaymentMethodViewStyle = PayPalSavedPaymentMethodViewStyle(), + completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void + ) { + self.payPalCheckoutRequest = payPalCheckoutRequest + self.request = request + self.style = style + _viewModel = StateObject( + wrappedValue: PayPalSavedPaymentMethodViewModel( + authorization: authorization, + universalLink: universalLink, + fallbackURLScheme: fallbackURLScheme, + completion: completion + ) + ) + } + + /// Internal initializer for previews and tests — seeds a concrete render state. + init( + viewModel: PayPalSavedPaymentMethodViewModel, + payPalCheckoutRequest: BTPayPalCheckoutRequest = BTPayPalCheckoutRequest(amount: "0"), + request: PayPalSavedPaymentMethodRequest = PayPalSavedPaymentMethodRequest(amount: "0", currencyCode: "USD"), + style: PayPalSavedPaymentMethodViewStyle = PayPalSavedPaymentMethodViewStyle() + ) { + self.payPalCheckoutRequest = payPalCheckoutRequest + self.request = request + self.style = style + _viewModel = StateObject(wrappedValue: viewModel) + } + + // MARK: - View + + public var body: some View { + Group { + if hasNothingToShow { + EmptyView() + } else { + container + } + } + .onAppear { + viewModel.onAppear(request: request, showCreditMessaging: style.showPayPalCreditMessaging) + } + .onChange(of: request) { newRequest in + viewModel.requestChanged(newRequest, showCreditMessaging: style.showPayPalCreditMessaging) + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + viewModel.appReturnedToForeground() + } + .sheet(item: $viewModel.presentedLander) { lander in + PayPalCreditMessagingLanderView(url: lander.url) { + viewModel.presentedLander = nil + } + } + .fullScreenCover( + isPresented: editLoaderBinding, + onDismiss: { viewModel.editLoaderDidDismiss() }, + content: { + EditFlowLoadingView() + .clearPresentationBackground() + .interactiveDismissDisabled(true) + .onAppear { viewModel.editLoaderDidAppear() } + } + ) + } + + /// Read-only binding: the loader is dismissed by the view model, not by user interaction. + private var editLoaderBinding: Binding { + Binding(get: { viewModel.isEditing }, set: { _ in }) + } + + private var container: some View { + VStack( + alignment: .leading, + spacing: viewModel.fiState == .loading + ? EditFIStyleDefaultConstants.loadingRowSpacing + : EditFIStyleDefaultConstants.rowSpacing + ) { + fiRegion + creditRegion + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding( + .horizontal, + EditFIStyleGuard.dimension( + style.container?.horizontalPadding, + default: EditFIStyleDefaultConstants.containerHorizontalPadding + ) + ) + .padding( + .vertical, + EditFIStyleGuard.dimension( + style.container?.verticalPadding, + default: EditFIStyleDefaultConstants.containerVerticalPadding + ) + ) + .frame(height: EditFIStyleGuard.containerHeight(style.container?.height), alignment: .center) + .background(Color(uiColor: EditFIStyleGuard.backgroundColor(style.componentAppearance?.backgroundColor))) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke( + Color(uiColor: EditFIStyleGuard.containerBorderColor(style.container?.borderColor)), + lineWidth: EditFIStyleGuard.dimension( + style.container?.borderWidth, + default: EditFIStyleDefaultConstants.containerBorderWidth + ) + ) + ) + } + + private var cornerRadius: CGFloat { + EditFIStyleGuard.dimension(style.container?.cornerRadius, default: EditFIStyleDefaultConstants.containerCornerRadius) + } + + @ViewBuilder private var fiRegion: some View { + switch viewModel.fiState { + case .loading: + PayPalSavedPaymentMethodSkeletonRow(style: style) + case .instrument(let summary): + EditFIRow(content: .instrument(summary), style: style, onEdit: editTapped) + case let .displayOnly(email, isEditable): + EditFIRow(content: .displayOnly(email: email, isEditable: isEditable), style: style, onEdit: editTapped) + case .brandOnly: + EditFIRow(content: .brandOnly, style: style, onEdit: editTapped) + } + } + + /// Brand-only with the merchant hiding both the logo and the label would draw an empty tile. + private var hasNothingToShow: Bool { + viewModel.fiState == .brandOnly && !style.showPayPalLogo && !style.showPayPalLabel + } + + private func editTapped() { + viewModel.editTapped(checkoutRequest: payPalCheckoutRequest, request: request) + } + + @ViewBuilder private var creditRegion: some View { + if style.showPayPalCreditMessaging, viewModel.showsCreditMessaging { + Group { + if let content = viewModel.creditMessage { + CreditMessagingRow( + style: style, + message: content.message, + learnMoreText: content.learnMore?.text, + learnMoreURL: content.learnMore?.url + ) { + viewModel.learnMoreTapped() + } + } else if viewModel.fiState == .loading { + CreditMessageSkeleton() + } + } + .padding(.leading, creditLeadingInset) + } + } + + /// Leading inset that aligns the credit-messaging line with the "PayPal" label (i.e. past + /// the logo). Zero when the logo is hidden and the label already starts at the leading edge. + private var creditLeadingInset: CGFloat { + guard style.showPayPalLogo else { return 0 } + let logoSide = EditFIStyleGuard.dimension( + style.container?.logo?.width, + default: EditFIStyleDefaultConstants.payPalLogoSide + ) + let labelGap = EditFIStyleGuard.dimension( + style.container?.label?.leadingGap, + default: EditFIStyleDefaultConstants.labelLeadingGap + ) + return logoSide + labelGap + } +} + +// MARK: - Edit-flow loader + +/// Full-screen loader shown while the edit paysheet (create-payment-resource) is being prepared. +private struct EditFlowLoadingView: View { + + @State private var rotation = 0.0 + + var body: some View { + ZStack { + Color.white.opacity(EditFIStyleDefaultConstants.editLoaderOverlayOpacity).ignoresSafeArea() + Image("LoadingSpinner", bundle: .payPalSavedPaymentMethod) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame( + width: EditFIStyleDefaultConstants.editLoaderSpinnerSide, + height: EditFIStyleDefaultConstants.editLoaderSpinnerSide + ) + .foregroundColor(Color(uiColor: EditFIStyleDefaultConstants.editLoaderSpinnerColor)) + .rotationEffect(.degrees(rotation)) + } + .onAppear { + withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + rotation = 360 + } + } + } +} + +private extension View { + + /// Makes a `fullScreenCover` background see-through (iOS 16.4+) so the merchant's screen shows + /// faintly behind the loader; a no-op on earlier versions (opaque backdrop). + @ViewBuilder func clearPresentationBackground() -> some View { + if #available(iOS 16.4, *) { + presentationBackground(.clear) + } else { + self + } + } +} + +// MARK: - Previews + +struct PayPalSavedPaymentMethodView_Previews: PreviewProvider { + + private static func preview( + _ title: String, + _ state: PayPalSavedPaymentMethodViewModel.FIState, + style: PayPalSavedPaymentMethodViewStyle = PayPalSavedPaymentMethodViewStyle() + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.caption).foregroundColor(.secondary) + PayPalSavedPaymentMethodView( + viewModel: PayPalSavedPaymentMethodViewModel(previewState: state), + style: style + ) + .border(Color.gray.opacity(0.2)) + } + } + + /// Builds a data-layer instrument for previews, which only has a failable JSON initializer. + private static func previewInstrument( + type: String, + label: String, + lastDigits: String, + imageURL: String? = nil + ) -> PayPalSavedPaymentMethod { + var json: [String: Any] = ["type": type, "label": label, "lastDigits": lastDigits] + if let imageURL { + json["imageUrl"] = imageURL + } + // Force-unwrapped: the literal above is always a valid object. + // swiftlint:disable:next force_unwrapping + return PayPalSavedPaymentMethod(json: BTJSON(value: json))! + } + + private static var borderedStyle: PayPalSavedPaymentMethodViewStyle { + PayPalSavedPaymentMethodViewStyle( + container: .init(horizontalPadding: 12, cornerRadius: 8, borderColor: .systemGray4, borderWidth: 1) + ) + } + + static var previews: some View { + let cardWithArt = previewInstrument( + type: "CARD", + label: "Visa", + lastDigits: "0199", + imageURL: "https://www.paypalobjects.com/visa.png" + ) + let bank = previewInstrument(type: "BANK", label: "CREDIT UNION 1", lastDigits: "3357") + let longLabel = previewInstrument( + type: "CARD", + label: "A Very Long Funding Instrument Bank Name", + lastDigits: "1234" + ) + let mastercard = previewInstrument(type: "CARD", label: "Mastercard", lastDigits: "4444") + + return ScrollView { + VStack(alignment: .leading, spacing: 16) { + preview("Loading (skeleton)", .loading) + preview("Instrument — card with art", .instrument(cardWithArt)) + preview("Instrument — no image (fallback glyph)", .instrument(bank)) + preview("Instrument — truncation", .instrument(longLabel)) + preview("Display-only (email)", .displayOnly(email: "buyer@example.com", isEditable: true)) + preview("Brand only (no network)", .brandOnly) + preview("Bordered container", .instrument(mastercard), style: borderedStyle) + } + .padding() + } + } +} diff --git a/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodViewModel.swift b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodViewModel.swift index 106ebbcc2..e2a8b5167 100644 --- a/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodViewModel.swift +++ b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodViewModel.swift @@ -25,15 +25,21 @@ final class PayPalSavedPaymentMethodViewModel: ObservableObject { case instrument(PayPalSavedPaymentMethod) /// PayPal has no FI to show, but knows the buyer's email; `isEditable` says whether the buyer can change the FI. case displayOnly(email: String, isEditable: Bool) - /// The FI could not be fetched (e.g. a network failure), so only PayPal is identified. + /// PayPal can't show an FI or an email (a failed fetch, an empty response, or a failed refresh after an + /// edit), so only the PayPal brand mark is shown. case brandOnly - /// PayPal has neither an FI nor an email for the buyer, so there is nothing to show. - case hidden } /// The merchant's `(nonce, error)` for an edit. private typealias EditResult = (nonce: BTPayPalAccountNonce?, error: Error?) + /// An in-app "Learn more" lander. + struct Lander: Identifiable, Equatable { + + let url: URL + var id: URL { url } + } + /// Where the edit flow is, from the tap until the merchant receives the result. private enum EditPhase { /// No edit is running and the loader is off screen. @@ -53,8 +59,9 @@ final class PayPalSavedPaymentMethodViewModel: ObservableObject { /// What the FI region currently shows. @Published private(set) var fiState: FIState - /// Whether the in-app "Learn more" lander sheet is showing. - @Published var isLanderPresented = false + /// The "Learn more" lander showing in-app, or `nil`. Holds its own URL so the sheet stays filled even if the + /// credit message is cleared while it's open. + @Published var presentedLander: Lander? /// Whether the full-screen loader should be showing. var isEditing: Bool { @@ -75,11 +82,6 @@ final class PayPalSavedPaymentMethodViewModel: ObservableObject { /// Exposed for testing to inject a fake application. var application: URLOpener = UIApplication.shared - /// The "Learn more" lander URL of the current credit message. - var learnMoreURL: URL? { - creditMessage?.learnMore?.url - } - /// The Pay Later offer is quoted against the pre-edit funding instrument and is only actionable if the /// buyer can change it, so it tracks the FI region and stays hidden once an edit completes. /// Derived rather than latched: the two fetches race, and this is re-read on every render. @@ -91,7 +93,7 @@ final class PayPalSavedPaymentMethodViewModel: ObservableObject { return true case .displayOnly(_, let isEditable): return isEditable - case .brandOnly, .hidden: + case .brandOnly: return false } } @@ -195,8 +197,7 @@ final class PayPalSavedPaymentMethodViewModel: ObservableObject { } /// Maps a fetched summary into a render state. Funding instrument wins; else the display-only - /// payer (email); else the component hides entirely (a network failure keeps the brand mark - /// via the `loadBuyerDefaultBillingAgreement` catch instead). + /// payer (email); else only the PayPal brand mark. static func state(from summary: PayPalSavedPaymentMethodSummary) -> FIState { if let instrument = summary.paymentMethods.first { return .instrument(instrument) @@ -206,7 +207,7 @@ final class PayPalSavedPaymentMethodViewModel: ObservableObject { // pencil we cannot confirm the buyer is allowed to use. return .displayOnly(email: email, isEditable: payer.isEditable ?? false) } - return .hidden + return .brandOnly } /// Starts the edit flow; taps while an edit or its loader is still up are ignored. @@ -269,7 +270,7 @@ final class PayPalSavedPaymentMethodViewModel: ObservableObject { guard let learnMore = creditMessage?.learnMore else { return } if learnMore.isEmbeddable { - isLanderPresented = true + presentedLander = Lander(url: learnMore.url) } else { application.open(learnMore.url, options: [:], completionHandler: nil) } @@ -353,7 +354,7 @@ final class PayPalSavedPaymentMethodViewModel: ObservableObject { fiRefetchTask?.cancel() guard let orderID = nonce.paymentID else { - fiState = .hidden + fiState = .brandOnly return } @@ -363,7 +364,7 @@ final class PayPalSavedPaymentMethodViewModel: ObservableObject { } } - /// Resolves the FI the buyer picked for `orderID`. A failure hides the row, since the pre-edit + /// Resolves the FI the buyer picked for `orderID`. A failure shows only the brand mark, since the pre-edit /// instrument is no longer the one that will be charged. private func loadBuyerUpdatedBillingAgreement(orderID: String, request: PayPalSavedPaymentMethodRequest) async { guard let fetchClient else { return } @@ -377,7 +378,7 @@ final class PayPalSavedPaymentMethodViewModel: ObservableObject { ) state = Self.state(from: summary) } catch { - state = .hidden + state = .brandOnly } guard !Task.isCancelled else { return } diff --git a/Sources/BraintreePayPalSavedPaymentMethod/Resources/Assets.xcassets/LoadingSpinner.imageset/Contents.json b/Sources/BraintreePayPalSavedPaymentMethod/Resources/Assets.xcassets/LoadingSpinner.imageset/Contents.json new file mode 100644 index 000000000..654ef96f8 --- /dev/null +++ b/Sources/BraintreePayPalSavedPaymentMethod/Resources/Assets.xcassets/LoadingSpinner.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LoadingSpinner.pdf" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/BraintreePayPalSavedPaymentMethod/Resources/Assets.xcassets/LoadingSpinner.imageset/LoadingSpinner.pdf b/Sources/BraintreePayPalSavedPaymentMethod/Resources/Assets.xcassets/LoadingSpinner.imageset/LoadingSpinner.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e138c3257b004196c4c33b09e47cb9189f04e037 GIT binary patch literal 5977 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 literal 0 HcmV?d00001 diff --git a/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodViewModel_Tests.swift b/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodViewModel_Tests.swift index e863f3904..f23cb6874 100644 --- a/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodViewModel_Tests.swift +++ b/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodViewModel_Tests.swift @@ -109,7 +109,7 @@ final class PayPalSavedPaymentMethodViewModel_Tests: XCTestCase { ) } - /// Drives a real credit-messaging fetch so `creditMessage` and `learnMoreURL` are populated + /// Drives a real credit-messaging fetch so `creditMessage` and its "Learn more" link are populated /// the same way they are in production. private func seedCreditMessage( on sut: PayPalSavedPaymentMethodViewModel, @@ -456,8 +456,8 @@ final class PayPalSavedPaymentMethodViewModel_Tests: XCTestCase { XCTAssertFalse(sut.showsCreditMessaging) } - /// The pre-edit instrument will no longer be charged, so it must not be shown again. - func testEdit_whenTheRefetchFails_hidesTheRowButStillDeliversTheNonce() async throws { + /// The pre-edit instrument will no longer be charged, so only the brand mark is shown. + func testEdit_whenTheRefetchFails_showsTheBrandMarkAndStillDeliversTheNonce() async throws { try injectPayPalClient(nonce: "fake-nonce", orderID: "fake-order-id") mockAPIClient.cannedResponseBody = Self.instrumentResponse() var receivedNonce: BTPayPalAccountNonce? @@ -470,7 +470,8 @@ final class PayPalSavedPaymentMethodViewModel_Tests: XCTestCase { await settle(sut) sut.editLoaderDidDismiss() - XCTAssertEqual(sut.fiState, .hidden) + XCTAssertEqual(sut.fiState, .brandOnly) + XCTAssertFalse(sut.showsCreditMessaging) XCTAssertEqual(receivedNonce?.nonce, "fake-nonce") } @@ -515,17 +516,32 @@ final class PayPalSavedPaymentMethodViewModel_Tests: XCTestCase { sut.learnMoreTapped() - XCTAssertTrue(sut.isLanderPresented) + XCTAssertEqual(sut.presentedLander?.url.absoluteString, "https://example.com/lander") XCTAssertNil(fakeApplication.lastOpenURL) } + /// The credit message can be cleared while the lander is open (e.g. a failed fetch for a new amount). + func testLearnMoreTapped_whenTheMessageIsClearedWhileOpen_keepsTheLanderURL() async { + let sut = makeSUT() + await seedCreditMessage(on: sut, clickURL: "https://example.com/lander", isEmbeddable: true) + sut.learnMoreTapped() + + mockAPIClient.cannedResponseBody = nil + mockAPIClient.cannedResponseError = NSError(domain: "com.example.error", code: 1) + sut.requestChanged(makeRequest(amount: "70.00"), showCreditMessaging: true) + await settle(sut) + + XCTAssertNil(sut.creditMessage) + XCTAssertEqual(sut.presentedLander?.url.absoluteString, "https://example.com/lander") + } + func testLearnMoreTapped_whenTheMessageIsNotEmbeddable_opensExternally() async { let sut = makeSUT() await seedCreditMessage(on: sut, clickURL: "https://example.com/lander", isEmbeddable: false) sut.learnMoreTapped() - XCTAssertFalse(sut.isLanderPresented) + XCTAssertNil(sut.presentedLander) XCTAssertEqual(fakeApplication.lastOpenURL?.absoluteString, "https://example.com/lander") } @@ -536,7 +552,7 @@ final class PayPalSavedPaymentMethodViewModel_Tests: XCTestCase { sut.learnMoreTapped() - XCTAssertFalse(sut.isLanderPresented) + XCTAssertNil(sut.presentedLander) XCTAssertNil(fakeApplication.lastOpenURL) } @@ -560,10 +576,10 @@ final class PayPalSavedPaymentMethodViewModel_Tests: XCTestCase { } } - func testStateFromSummary_whenNeitherIsPresent_hidesTheComponent() throws { + func testStateFromSummary_whenNeitherIsPresent_showsTheBrandMark() throws { let summary = try XCTUnwrap(PayPalSavedPaymentMethodSummary(json: BTJSON(value: [:] as [String: Any]))) - XCTAssertEqual(PayPalSavedPaymentMethodViewModel.state(from: summary), .hidden) + XCTAssertEqual(PayPalSavedPaymentMethodViewModel.state(from: summary), .brandOnly) } } diff --git a/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodView_RenderTests.swift b/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodView_RenderTests.swift new file mode 100644 index 000000000..40514e6c7 --- /dev/null +++ b/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodView_RenderTests.swift @@ -0,0 +1,195 @@ +import SafariServices +import SwiftUI +import XCTest +@testable import BraintreePayPalSavedPaymentMethod + +/// Renders the composed view in every state and style permutation. +final class PayPalSavedPaymentMethodView_RenderTests: SavedPaymentMethodRenderTestCase { + + // MARK: - Helpers + + private func view( + state: PayPalSavedPaymentMethodViewModel.FIState, + showCreditMessage: Bool = false, + style: PayPalSavedPaymentMethodViewStyle = PayPalSavedPaymentMethodViewStyle() + ) -> PayPalSavedPaymentMethodView { + let creditMessage = CreditMessageContent( + message: "Or 4 interest-free payments of $324.50.", + learnMore: URL(string: "https://example.com/lander").map { + CreditMessageContent.LearnMore(text: "Learn more", url: $0, isEmbeddable: false) + } + ) + return PayPalSavedPaymentMethodView( + viewModel: PayPalSavedPaymentMethodViewModel( + previewState: state, + creditMessage: showCreditMessage ? creditMessage : nil + ), + style: style + ) + } + + // MARK: - Render states + + /// Every instrument shape the API can return. These share one render path, so they are driven + /// from a table rather than repeated as separate tests. + func testRender_everyInstrumentVariant() throws { + let variants: [(name: String, fi: PayPalSavedPaymentMethod)] = [ + ("card", try instrument()), + ("card with remote art", try instrument(imageURL: "https://example.com/visa.png")), + ("card without art", try instrument(imageURL: nil)), + ("bank", try instrument(type: "BANK", label: "CREDIT UNION 1", lastDigits: "0199")), + ("paypal credit", try instrument(type: "PAYPAL_CREDIT", label: "PayPal Credit")), + ("unrecognised type", try instrument(type: "SOME_FUTURE_TYPE", label: "Mystery")), + ("missing label and digits", try instrument(label: nil, lastDigits: nil)) + ] + + for variant in variants { + XCTAssertNotNil(rendered(view(state: .instrument(variant.fi))), variant.name) + } + } + + /// The non-instrument states. + func testRender_everyNonInstrumentState() { + let states: [(name: String, state: PayPalSavedPaymentMethodViewModel.FIState)] = [ + ("loading", .loading), + ("email, editable", .displayOnly(email: "buyer@example.com", isEditable: true)), + ("email, not editable", .displayOnly(email: "buyer@example.com", isEditable: false)), + ("brand only", .brandOnly) + ] + + for entry in states { + XCTAssertNotNil(rendered(view(state: entry.state)), entry.name) + } + } + + /// With the logo and label both hidden, brand-only has nothing to show, so it must not render an empty tile. + func testRender_brandOnly_withLogoAndLabelHidden_occupiesNoSpace() { + let style = PayPalSavedPaymentMethodViewStyle(showPayPalLogo: false, showPayPalLabel: false) + + XCTAssertNil(rendered(view(state: .brandOnly, style: style))) + } + + // MARK: - Rendered content + + func testRender_withACreditMessage_isTallerThanWithout() throws { + let fi = try instrument() + + let withMessage = try render(view(state: .instrument(fi), showCreditMessage: true)) + let withoutMessage = try render(view(state: .instrument(fi))) + + XCTAssertGreaterThan(withMessage.size.height, withoutMessage.size.height) + } + + /// Brand-only shows both the logo and the label, and each one respects its style flag. + func testRender_brandOnly_drawsTheLogoAndTheLabel() throws { + let both = try render(view(state: .brandOnly)).pngData() + let logoOnly = try render(view(state: .brandOnly, style: .init(showPayPalLabel: false))).pngData() + let labelOnly = try render(view(state: .brandOnly, style: .init(showPayPalLogo: false))).pngData() + + XCTAssertNotEqual(both, logoOnly) + XCTAssertNotEqual(both, labelOnly) + XCTAssertNotEqual(logoOnly, labelOnly) + } + + func testRender_editableEmail_drawsTheEditPencil() throws { + let editable = try render(view(state: .displayOnly(email: "buyer@example.com", isEditable: true))).pngData() + let readOnly = try render(view(state: .displayOnly(email: "buyer@example.com", isEditable: false))).pngData() + + XCTAssertNotEqual(editable, readOnly) + } + + // MARK: - Style permutations + + /// Merchant style permutations that must all lay out. The cases that collapse the component + /// are asserted separately below, since they are expected to render nothing. + func testRender_everyStylePermutation() throws { + let fi = try instrument() + + let logoAndLabelHidden = PayPalSavedPaymentMethodViewStyle(showPayPalLogo: false, showPayPalLabel: false) + + let creditMessagingOff = PayPalSavedPaymentMethodViewStyle(showPayPalCreditMessaging: false) + + let fullyCustomised = PayPalSavedPaymentMethodViewStyle( + componentAppearance: .init( + backgroundColor: .systemPink, + textColor: .white, + baseFontSize: 18, + fontName: "Georgia" + ), + container: .init( + height: 120, + horizontalPadding: 20, + verticalPadding: 12, + cornerRadius: 16, + borderColor: .systemBlue, + borderWidth: 2, + logo: .init(width: 32), + label: .init(fontSize: 20, leadingGap: 10), + fundingInstrument: .init(textFontSize: 16, editIconSize: 24, leadingGap: 8), + creditMessaging: .init(fontSize: 13, linkColor: .systemGreen) + ) + ) + + // Negative everywhere except height: the guard clamps each value rather than trapping. + let negatives = PayPalSavedPaymentMethodViewStyle( + componentAppearance: .init(baseFontSize: -20), + container: .init( + horizontalPadding: -10, + verticalPadding: -10, + cornerRadius: -8, + borderWidth: -4, + logo: .init(width: -30), + label: .init(fontSize: -12, leadingGap: -6), + fundingInstrument: .init(textFontSize: -14, editIconSize: -20, leadingGap: -5), + creditMessaging: .init(fontSize: -11) + ) + ) + + let zeroPadding = PayPalSavedPaymentMethodViewStyle( + container: .init(horizontalPadding: 0, verticalPadding: 0, cornerRadius: 0, borderWidth: 0) + ) + + let styles: [(name: String, style: PayPalSavedPaymentMethodViewStyle)] = [ + ("logo and label hidden", logoAndLabelHidden), + ("credit messaging disabled", creditMessagingOff), + ("fully customised", fullyCustomised), + ("negative values, intrinsic height", negatives), + ("zero padding, intrinsic height", zeroPadding) + ] + + for entry in styles { + XCTAssertNotNil( + rendered(view(state: .instrument(fi), showCreditMessage: true, style: entry.style)), + entry.name + ) + } + } + + /// `EditFIStyleGuard` clamps negatives to zero so a merchant can never hand SwiftUI a + /// negative frame. An explicit negative height collapses the component rather than trapping. + func testRender_withNegativeHeight_collapsesInsteadOfTrapping() throws { + let style = PayPalSavedPaymentMethodViewStyle(container: .init(height: -100)) + + XCTAssertEqual(EditFIStyleGuard.containerHeight(-100), 0) + XCTAssertNil(rendered(view(state: .instrument(try instrument()), showCreditMessage: true, style: style))) + } + + // MARK: - Lander + + /// Safari's "Done" button must clear the presenter's state, or the sheet can't be shown again. + func testLander_whenSafariFinishes_callsOnFinish() throws { + let url = try XCTUnwrap(URL(string: "https://example.com/lander")) + var didFinish = false + let coordinator = PayPalCreditMessagingLanderView(url: url) { didFinish = true }.makeCoordinator() + + coordinator.safariViewControllerDidFinish(SFSafariViewController(url: url)) + + XCTAssertTrue(didFinish) + } + + // MARK: - Assets + + func testBundle_containsTheLoadingSpinner() { + XCTAssertNotNil(UIImage(named: "LoadingSpinner", in: .payPalSavedPaymentMethod, compatibleWith: nil)) + } +} From a6632ef37ed9c3074ccc425c98303c4a6081289c Mon Sep 17 00:00:00 2001 From: Saurabh Pandey Date: Tue, 20 Oct 2026 02:54:15 +0530 Subject: [PATCH 2/3] Move shimmer and spinner values to constants, drop the skeleton's brand gap when hidden, group the view's members, and add a changelog entry --- CHANGELOG.md | 2 + .../EditFIStyleGuard.swift | 6 +++ .../PayPalSavedPaymentMethodSkeletonRow.swift | 29 +++++++---- .../PayPalSavedPaymentMethodView.swift | 51 +++++++++++-------- ...PayPalSavedPaymentMethodBundle_Tests.swift | 2 +- ...alSavedPaymentMethodView_RenderTests.swift | 6 --- 6 files changed, 59 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6c17549..060351b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## unreleased * BraintreePayPal * Send the `edit_billing_agreement_jwt` sourced from the client token for the PayPal Edit FI beta flow +* BraintreePayPalSavedPaymentMethod + * Add `PayPalSavedPaymentMethodView` (beta) to show a returning buyer's saved PayPal funding instrument at checkout and let them change it ## 7.9.0 (2026-07-21) * BraintreeUIComponents diff --git a/Sources/BraintreePayPalSavedPaymentMethod/EditFIStyleGuard.swift b/Sources/BraintreePayPalSavedPaymentMethod/EditFIStyleGuard.swift index a1a655a69..3aac20604 100644 --- a/Sources/BraintreePayPalSavedPaymentMethod/EditFIStyleGuard.swift +++ b/Sources/BraintreePayPalSavedPaymentMethod/EditFIStyleGuard.swift @@ -59,10 +59,16 @@ enum EditFIStyleDefaultConstants { static let shimmerBarCornerRadius: CGFloat = 4 /// Space left after the shimmer bar so it stops short of the trailing edge. static let shimmerBarTrailingGap: CGFloat = 40 + static let shimmerBarColor = UIColor.systemGray5 + static let shimmerHighlightOpacity: Double = 0.65 + /// The sweeping highlight's width, as a fraction of the bar's width. + static let shimmerHighlightWidthRatio: CGFloat = 0.6 + static let shimmerDuration: Double = 1.2 static let editLoaderOverlayOpacity: Double = 0.9 static let editLoaderSpinnerSide: CGFloat = 48 static let editLoaderSpinnerColor = UIColor.black + static let editLoaderSpinnerRotationDuration: Double = 1 } /// Resolves `PayPalSavedPaymentMethodViewStyle` values for rendering. diff --git a/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodSkeletonRow.swift b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodSkeletonRow.swift index d1235d779..fd74f22ca 100644 --- a/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodSkeletonRow.swift +++ b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodSkeletonRow.swift @@ -9,18 +9,21 @@ struct ShimmerModifier: ViewModifier { content .overlay( GeometryReader { geo in + let highlightWidth = geo.size.width * EditFIStyleDefaultConstants.shimmerHighlightWidthRatio LinearGradient( - gradient: Gradient(colors: [.clear, Color.white.opacity(0.65), .clear]), + gradient: Gradient( + colors: [.clear, Color.white.opacity(EditFIStyleDefaultConstants.shimmerHighlightOpacity), .clear] + ), startPoint: .leading, endPoint: .trailing ) - .frame(width: geo.size.width * 0.6) - .offset(x: animating ? geo.size.width : -geo.size.width * 0.6) + .frame(width: highlightWidth) + .offset(x: animating ? geo.size.width : -highlightWidth) } ) .mask(content) .onAppear { - withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + withAnimation(.linear(duration: EditFIStyleDefaultConstants.shimmerDuration).repeatForever(autoreverses: false)) { animating = true } } @@ -40,7 +43,7 @@ struct ShimmerBar: View { var body: some View { RoundedRectangle(cornerRadius: EditFIStyleDefaultConstants.shimmerBarCornerRadius) - .fill(Color(.systemGray5)) + .fill(Color(uiColor: EditFIStyleDefaultConstants.shimmerBarColor)) .frame(height: EditFIStyleDefaultConstants.shimmerBarHeight) .frame(maxWidth: .infinity) .padding(.trailing, EditFIStyleDefaultConstants.shimmerBarTrailingGap) @@ -54,12 +57,20 @@ struct PayPalSavedPaymentMethodSkeletonRow: View { let style: PayPalSavedPaymentMethodViewStyle - var body: some View { - HStack(spacing: EditFIStyleGuard.dimension( + private var fundingInstrumentGap: CGFloat { + EditFIStyleGuard.dimension( style.container?.fundingInstrument?.leadingGap, default: EditFIStyleDefaultConstants.fundingInstrumentLeadingGap - )) { - PayPalBrandCluster(style: style) + ) + } + + var body: some View { + HStack(spacing: 0) { + // A hidden brand mark would still take the gap, so it is left out entirely. + if style.showPayPalLogo || style.showPayPalLabel { + PayPalBrandCluster(style: style) + .padding(.trailing, fundingInstrumentGap) + } ShimmerBar() } .accessibilityElement() diff --git a/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift index 1557551ef..930e1882a 100644 --- a/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift +++ b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift @@ -79,7 +79,7 @@ public struct PayPalSavedPaymentMethodView: View { _viewModel = StateObject(wrappedValue: viewModel) } - // MARK: - View + // MARK: - Body public var body: some View { Group { @@ -115,10 +115,7 @@ public struct PayPalSavedPaymentMethodView: View { ) } - /// Read-only binding: the loader is dismissed by the view model, not by user interaction. - private var editLoaderBinding: Binding { - Binding(get: { viewModel.isEditing }, set: { _ in }) - } + // MARK: - Subviews private var container: some View { VStack( @@ -160,10 +157,6 @@ public struct PayPalSavedPaymentMethodView: View { ) } - private var cornerRadius: CGFloat { - EditFIStyleGuard.dimension(style.container?.cornerRadius, default: EditFIStyleDefaultConstants.containerCornerRadius) - } - @ViewBuilder private var fiRegion: some View { switch viewModel.fiState { case .loading: @@ -177,15 +170,6 @@ public struct PayPalSavedPaymentMethodView: View { } } - /// Brand-only with the merchant hiding both the logo and the label would draw an empty tile. - private var hasNothingToShow: Bool { - viewModel.fiState == .brandOnly && !style.showPayPalLogo && !style.showPayPalLabel - } - - private func editTapped() { - viewModel.editTapped(checkoutRequest: payPalCheckoutRequest, request: request) - } - @ViewBuilder private var creditRegion: some View { if style.showPayPalCreditMessaging, viewModel.showsCreditMessaging { Group { @@ -206,6 +190,22 @@ public struct PayPalSavedPaymentMethodView: View { } } + // MARK: - Helpers + + /// Read-only binding: the loader is dismissed by the view model, not by user interaction. + private var editLoaderBinding: Binding { + Binding(get: { viewModel.isEditing }, set: { _ in }) + } + + private var cornerRadius: CGFloat { + EditFIStyleGuard.dimension(style.container?.cornerRadius, default: EditFIStyleDefaultConstants.containerCornerRadius) + } + + /// Brand-only with the merchant hiding both the logo and the label would draw an empty tile. + private var hasNothingToShow: Bool { + viewModel.fiState == .brandOnly && !style.showPayPalLogo && !style.showPayPalLabel + } + /// Leading inset that aligns the credit-messaging line with the "PayPal" label (i.e. past /// the logo). Zero when the logo is hidden and the label already starts at the leading edge. private var creditLeadingInset: CGFloat { @@ -220,11 +220,17 @@ public struct PayPalSavedPaymentMethodView: View { ) return logoSide + labelGap } + + // MARK: - Private Methods + + private func editTapped() { + viewModel.editTapped(checkoutRequest: payPalCheckoutRequest, request: request) + } } // MARK: - Edit-flow loader -/// Full-screen loader shown while the edit paysheet (create-payment-resource) is being prepared. +/// Full-screen loader shown while the PayPal edit flow is being prepared. private struct EditFlowLoadingView: View { @State private var rotation = 0.0 @@ -244,7 +250,10 @@ private struct EditFlowLoadingView: View { .rotationEffect(.degrees(rotation)) } .onAppear { - withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + withAnimation( + .linear(duration: EditFIStyleDefaultConstants.editLoaderSpinnerRotationDuration) + .repeatForever(autoreverses: false) + ) { rotation = 360 } } @@ -327,7 +336,7 @@ struct PayPalSavedPaymentMethodView_Previews: PreviewProvider { preview("Instrument — no image (fallback glyph)", .instrument(bank)) preview("Instrument — truncation", .instrument(longLabel)) preview("Display-only (email)", .displayOnly(email: "buyer@example.com", isEditable: true)) - preview("Brand only (no network)", .brandOnly) + preview("Brand only", .brandOnly) preview("Bordered container", .instrument(mastercard), style: borderedStyle) } .padding() diff --git a/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodBundle_Tests.swift b/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodBundle_Tests.swift index f295691e2..ff0c2c02e 100644 --- a/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodBundle_Tests.swift +++ b/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodBundle_Tests.swift @@ -9,7 +9,7 @@ final class PayPalSavedPaymentMethodBundle_Tests: XCTestCase { func testBundle_containsTheRowAssets() { let bundle = Bundle.payPalSavedPaymentMethod - for asset in ["CardFundingIcon", "BankFundingIcon", "EditPencil", "PayPalBadge"] { + for asset in ["CardFundingIcon", "BankFundingIcon", "EditPencil", "LoadingSpinner", "PayPalBadge"] { XCTAssertNotNil( UIImage(named: asset, in: bundle, compatibleWith: nil), "\(asset) missing from \(bundle.bundlePath)" diff --git a/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodView_RenderTests.swift b/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodView_RenderTests.swift index 40514e6c7..5f30a62a0 100644 --- a/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodView_RenderTests.swift +++ b/UnitTests/BraintreePayPalSavedPaymentMethodTests/PayPalSavedPaymentMethodView_RenderTests.swift @@ -186,10 +186,4 @@ final class PayPalSavedPaymentMethodView_RenderTests: SavedPaymentMethodRenderTe XCTAssertTrue(didFinish) } - - // MARK: - Assets - - func testBundle_containsTheLoadingSpinner() { - XCTAssertNotNil(UIImage(named: "LoadingSpinner", in: .payPalSavedPaymentMethod, compatibleWith: nil)) - } } From f566d645c020869aca899a5a91e68ccbaf00932b Mon Sep 17 00:00:00 2001 From: Saurabh Pandey Date: Wed, 21 Oct 2026 11:17:46 +0530 Subject: [PATCH 3/3] Keep a zero-size view when the component collapses and drop the no-op interactiveDismissDisabled --- .../PayPalSavedPaymentMethodView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift index 930e1882a..f324ca63b 100644 --- a/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift +++ b/Sources/BraintreePayPalSavedPaymentMethod/PayPalSavedPaymentMethodView.swift @@ -84,7 +84,9 @@ public struct PayPalSavedPaymentMethodView: View { public var body: some View { Group { if hasNothingToShow { - EmptyView() + // A zero-size view rather than `EmptyView`, so the loader and lifecycle modifiers below stay + // attached if the component collapses mid-edit. + Color.clear.frame(width: 0, height: 0) } else { container } @@ -109,7 +111,6 @@ public struct PayPalSavedPaymentMethodView: View { content: { EditFlowLoadingView() .clearPresentationBackground() - .interactiveDismissDisabled(true) .onAppear { viewModel.editLoaderDidAppear() } } )