diff --git a/Example/Podfile b/Example/Podfile index cca2182..e852d38 100644 --- a/Example/Podfile +++ b/Example/Podfile @@ -1,6 +1,6 @@ use_frameworks! -platform :ios, '13.0' +platform :ios, '15.0' target 'paystack-sdk-ios' do @@ -13,7 +13,8 @@ post_install do |installer| installer.generated_projects.each do |project| project.targets.each do |target| target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + # Xcode 27 only supports deployment targets from iOS 15.0 upward. + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' # Expose TweetNacl's `CTweetNacl` C submodule to every pod target. # Without this, Xcode 16+/Swift 6 explicit-module builds fail to diff --git a/Example/paystack-sdk-ios.xcodeproj/project.pbxproj b/Example/paystack-sdk-ios.xcodeproj/project.pbxproj index e417584..8bf3cd4 100644 --- a/Example/paystack-sdk-ios.xcodeproj/project.pbxproj +++ b/Example/paystack-sdk-ios.xcodeproj/project.pbxproj @@ -284,7 +284,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -338,7 +338,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -365,7 +365,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -397,7 +397,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Example/paystack-sdk-ios/Info.plist b/Example/paystack-sdk-ios/Info.plist index 0a7d195..5bfbbda 100644 --- a/Example/paystack-sdk-ios/Info.plist +++ b/Example/paystack-sdk-ios/Info.plist @@ -2,10 +2,10 @@ - UIApplicationSceneManifest - - UISceneConfigurations - - + UIApplicationSceneManifest + + UISceneConfigurations + + diff --git a/README.md b/README.md index c6ba993..46120f5 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,19 @@ func paymentDone(_ result: TransactionResult) { } ``` +### Display audience +By default the payment UI assumes the customer is holding the device (`.customerFacing`). If your app runs on a device the merchant holds and shows to the customer, such as a point-of-sale or terminal app, set the display audience when you build the Paystack object: + +```swift +let paystackObject = try? PaystackBuilder + .newInstance + .setKey("PUBLIC KEY GOES HERE") + .setDisplayAudience(.merchantFacing) + .build() +``` + +Some payment methods adapt to the audience. For example, Zap shows an "Open Zap" button when customer-facing and a QR code for the customer to scan when merchant-facing. + ### Handling the Transaction Result There are 3 possibly results that can be returned diff --git a/Sources/PaystackSDK/Core/DisplayAudience.swift b/Sources/PaystackSDK/Core/DisplayAudience.swift new file mode 100644 index 0000000..32c29e8 --- /dev/null +++ b/Sources/PaystackSDK/Core/DisplayAudience.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Describes who the Paystack payment UI is being shown to. +/// +/// Set it once on ``PaystackBuilder`` with ``PaystackBuilder/setDisplayAudience(_:)``. +/// The payment flows use it to decide which UI elements to show. When it isn't set, +/// ``customerFacing`` is used. +/// +/// Example Usage: +/// ```swift +/// let paystack = try PaystackBuilder.newInstance +/// .setKey("PUBLIC KEY GOES HERE") +/// .setDisplayAudience(.merchantFacing) +/// .build() +/// ``` +public struct DisplayAudience: Hashable { + + let id: String + + init(id: String) { + self.id = id + } + + /// The customer is holding the device and paying on it. This is the default. + public static let customerFacing = DisplayAudience(id: "customer_facing") + + /// The merchant is holding the device and showing the screen to the customer, + /// for example on a point-of-sale or terminal app. + public static let merchantFacing = DisplayAudience(id: "merchant_facing") +} diff --git a/Sources/PaystackSDK/Core/PaystackBuilder.swift b/Sources/PaystackSDK/Core/PaystackBuilder.swift index 5cbb5a7..dbf60b9 100644 --- a/Sources/PaystackSDK/Core/PaystackBuilder.swift +++ b/Sources/PaystackSDK/Core/PaystackBuilder.swift @@ -4,6 +4,7 @@ public class PaystackBuilder { private var apiKey: String? private var loggingEnabled = false + private var displayAudience: DisplayAudience = .customerFacing public func setKey(_ apiKey: String) -> Self { self.apiKey = apiKey @@ -15,6 +16,14 @@ public class PaystackBuilder { return self } + /// Sets who the payment UI is shown to. Defaults to ``DisplayAudience/customerFacing`` when not called. + /// - Parameter audience: The ``DisplayAudience`` the payment flows should be tailored for + /// - Returns: The builder, so calls can be chained + public func setDisplayAudience(_ audience: DisplayAudience) -> Self { + self.displayAudience = audience + return self + } + public func build() throws -> Paystack { guard let apiKey = apiKey else { throw PaystackError.noAPIKey @@ -22,7 +31,7 @@ public class PaystackBuilder { Logger.loggingEnabled = loggingEnabled - let config = PaystackConfig(apiKey: apiKey) + let config = PaystackConfig(apiKey: apiKey, displayAudience: displayAudience) return Paystack(config: config) } } diff --git a/Sources/PaystackSDK/Core/PaystackConfig.swift b/Sources/PaystackSDK/Core/PaystackConfig.swift index 51f2cc8..a1bf7ac 100644 --- a/Sources/PaystackSDK/Core/PaystackConfig.swift +++ b/Sources/PaystackSDK/Core/PaystackConfig.swift @@ -3,4 +3,5 @@ import Foundation public struct PaystackConfig { public var apiKey: String public var version: String = "2.1.0" + public var displayAudience: DisplayAudience = .customerFacing } diff --git a/Sources/PaystackUI/Charge/StartCharge.swift b/Sources/PaystackUI/Charge/StartCharge.swift index 97c2257..cec64e5 100644 --- a/Sources/PaystackUI/Charge/StartCharge.swift +++ b/Sources/PaystackUI/Charge/StartCharge.swift @@ -27,7 +27,8 @@ public extension Paystack { initializeSDK() return NavigationalButton( onComplete: onComplete, - destination: ChargeView(accessCode: accessCode)) { button() } + destination: ChargeView(accessCode: accessCode) + .environment(\.displayAudience, config.displayAudience)) { button() } } @@ -51,6 +52,7 @@ public extension Paystack { parentViewController: viewController) let chargeCardViewController = UIHostingController( rootView: ChargeView(accessCode: accessCode) + .environment(\.displayAudience, config.displayAudience) .environmentObject(visibilityContainer)) viewController.present(chargeCardViewController, animated: true) } diff --git a/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift b/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift index 0a356d1..08c6e40 100644 --- a/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift +++ b/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift @@ -4,7 +4,6 @@ import PaystackCore class ZapViewModel: ObservableObject { static var mandateWindowSeconds: Int = 5 * 60 - public static var showsOpenZapButton = true static var failedFallbackMessage = "Something went wrong" static var sessionExpiredCopy = diff --git a/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift b/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift index 4401019..0ca655e 100644 --- a/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift +++ b/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift @@ -11,7 +11,8 @@ struct ZapPaymentView: View { let remainingSeconds: Int let onChangePaymentMethod: () -> Void - let showsOpenZapButton: Bool + @Environment(\.displayAudience) + private var displayAudience private var formattedRemaining: String { let minutes = max(0, remainingSeconds) / 60 @@ -29,12 +30,13 @@ struct ZapPaymentView: View { zapHeader - Text("Open Zap or scan this QR code to complete this payment") + Text(displayAudience.text(.zapInstruction)) .font(.body16M) .foregroundColor(.contentPrimary) .multilineTextAlignment(.center) qrCodeBlock + .visible(for: .zapQRCode) expiresInRow @@ -78,10 +80,9 @@ struct ZapPaymentView: View { private var actionButtons: some View { VStack(spacing: .singlePadding) { - if showsOpenZapButton { - Button("Open Zap", action: openZap) - .buttonStyle(PrimaryButtonStyle(showLoading: false)) - } + Button("Open Zap", action: openZap) + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + .visible(for: .zapOpenAppButton) Button("Change payment method", action: onChangePaymentMethod) .foregroundColor(.contentSecondary) @@ -106,20 +107,20 @@ struct ZapPaymentView_Previews: PreviewProvider { Group { ZapPaymentView(details: .example, remainingSeconds: 254, - onChangePaymentMethod: {}, - showsOpenZapButton: true) - .previewDisplayName("Default — Open Zap + Change payment method") + onChangePaymentMethod: {}) + .environment(\.displayAudience, .customerFacing) + .previewDisplayName("Customer-facing — Open Zap, no QR") ZapPaymentView(details: .example, remainingSeconds: 254, - onChangePaymentMethod: {}, - showsOpenZapButton: false) - .previewDisplayName("QR-only (terminal mode)") + onChangePaymentMethod: {}) + .environment(\.displayAudience, .merchantFacing) + .previewDisplayName("Merchant-facing — QR only") ZapPaymentView(details: .example, remainingSeconds: 42, - onChangePaymentMethod: {}, - showsOpenZapButton: true) + onChangePaymentMethod: {}) + .environment(\.displayAudience, .merchantFacing) .previewDisplayName("Final 60s — countdown warning colour") } } diff --git a/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift b/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift index 4b69c5a..d0cc384 100644 --- a/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift +++ b/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift @@ -25,8 +25,7 @@ struct ZapView: View { ZapPaymentView( details: details, remainingSeconds: viewModel.remainingSeconds, - onChangePaymentMethod: viewModel.userTappedChangePaymentMethod, - showsOpenZapButton: ZapViewModel.showsOpenZapButton) + onChangePaymentMethod: viewModel.userTappedChangePaymentMethod) case .sessionExpired: ZapSessionExpiredView( message: ZapViewModel.sessionExpiredCopy, diff --git a/Sources/PaystackUI/Design/UIElementVisibility.swift b/Sources/PaystackUI/Design/UIElementVisibility.swift new file mode 100644 index 0000000..be191a6 --- /dev/null +++ b/Sources/PaystackUI/Design/UIElementVisibility.swift @@ -0,0 +1,57 @@ +import SwiftUI +import PaystackCore + +enum AudienceGatedElement: CaseIterable { + case zapQRCode + case zapOpenAppButton +} + +enum AudienceCopy: CaseIterable { + case zapInstruction +} + +extension DisplayAudience { + + func shows(_ element: AudienceGatedElement) -> Bool { + switch element { + case .zapQRCode: + return resolve(customerFacing: false, merchantFacing: true) + case .zapOpenAppButton: + return resolve(customerFacing: true, merchantFacing: false) + } + } + + func text(_ copy: AudienceCopy) -> String { + switch copy { + case .zapInstruction: + return resolve(customerFacing: "Open Zap to complete this payment", + merchantFacing: "Scan this QR code with Zap to complete this payment") + } + } + + private func resolve(customerFacing: Value, merchantFacing: Value) -> Value { + self == .merchantFacing ? merchantFacing : customerFacing + } +} + +private struct AudienceVisibilityModifier: ViewModifier { + + @Environment(\.displayAudience) + private var displayAudience + + let element: AudienceGatedElement + + @ViewBuilder + func body(content: Content) -> some View { + if displayAudience.shows(element) { + content + } + } +} + +extension View { + + func visible(for element: AudienceGatedElement) -> some View { + modifier(AudienceVisibilityModifier(element: element)) + } +} diff --git a/Sources/PaystackUI/Utils/DisplayAudienceEnvironment.swift b/Sources/PaystackUI/Utils/DisplayAudienceEnvironment.swift new file mode 100644 index 0000000..2b6741b --- /dev/null +++ b/Sources/PaystackUI/Utils/DisplayAudienceEnvironment.swift @@ -0,0 +1,13 @@ +import SwiftUI +import PaystackCore + +private struct DisplayAudienceKey: EnvironmentKey { + static let defaultValue: DisplayAudience = .customerFacing +} + +extension EnvironmentValues { + var displayAudience: DisplayAudience { + get { self[DisplayAudienceKey.self] } + set { self[DisplayAudienceKey.self] = newValue } + } +} diff --git a/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift b/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift index 35104c4..b67c513 100644 --- a/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift +++ b/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift @@ -25,4 +25,32 @@ class PaystackBuilderTests: XCTestCase { } } + func testDisplayAudienceDefaultsToCustomerFacing() throws { + let paystack = try builder + .setKey("testsk_exampleKey") + .build() + XCTAssertEqual(paystack.config.displayAudience, .customerFacing) + } + + func testSetDisplayAudienceSetsMerchantFacingOnConfig() throws { + let paystack = try builder + .setKey("testsk_exampleKey") + .setDisplayAudience(.merchantFacing) + .build() + XCTAssertEqual(paystack.config.displayAudience, .merchantFacing) + } + + func testSetDisplayAudienceLastCallWins() throws { + let paystack = try builder + .setKey("testsk_exampleKey") + .setDisplayAudience(.merchantFacing) + .setDisplayAudience(.customerFacing) + .build() + XCTAssertEqual(paystack.config.displayAudience, .customerFacing) + } + + func testDisplayAudienceValuesAreDistinct() { + XCTAssertNotEqual(DisplayAudience.customerFacing, DisplayAudience.merchantFacing) + } + } diff --git a/Tests/PaystackSDKTests/UI/Utils/UIElementVisibilityTests.swift b/Tests/PaystackSDKTests/UI/Utils/UIElementVisibilityTests.swift new file mode 100644 index 0000000..a4d24d3 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Utils/UIElementVisibilityTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import PaystackCore +@testable import PaystackUI + +final class UIElementVisibilityTests: XCTestCase { + + private let futureAudience = DisplayAudience(id: "future") + + func testZapQRCodeReturnsFalseWhenCustomerFacing() { + XCTAssertFalse(DisplayAudience.customerFacing.shows(.zapQRCode)) + } + + func testZapQRCodeReturnsTrueWhenMerchantFacing() { + XCTAssertTrue(DisplayAudience.merchantFacing.shows(.zapQRCode)) + } + + func testZapQRCodeFollowsCustomerFacingForUnknownAudience() { + XCTAssertFalse(futureAudience.shows(.zapQRCode)) + } + + func testZapOpenAppButtonReturnsTrueWhenCustomerFacing() { + XCTAssertTrue(DisplayAudience.customerFacing.shows(.zapOpenAppButton)) + } + + func testZapOpenAppButtonReturnsFalseWhenMerchantFacing() { + XCTAssertFalse(DisplayAudience.merchantFacing.shows(.zapOpenAppButton)) + } + + func testZapOpenAppButtonFollowsCustomerFacingForUnknownAudience() { + XCTAssertTrue(futureAudience.shows(.zapOpenAppButton)) + } + + func testZapShowsExactlyOnePaymentAffordanceForEveryAudience() { + for audience in [DisplayAudience.customerFacing, .merchantFacing, futureAudience] { + let visible = [audience.shows(.zapQRCode), audience.shows(.zapOpenAppButton)] + .filter { $0 } + XCTAssertEqual(visible.count, 1, "Audience \(audience.id) must show exactly one Zap payment option") + } + } + + func testZapInstructionTextForCustomerFacing() { + XCTAssertEqual(DisplayAudience.customerFacing.text(.zapInstruction), + "Open Zap to complete this payment") + } + + func testZapInstructionTextForMerchantFacing() { + XCTAssertEqual(DisplayAudience.merchantFacing.text(.zapInstruction), + "Scan this QR code with Zap to complete this payment") + } + + func testZapInstructionTextFallsBackToCustomerWordingForUnknownAudience() { + XCTAssertEqual(futureAudience.text(.zapInstruction), + DisplayAudience.customerFacing.text(.zapInstruction)) + } +}