Skip to content
Merged
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
5 changes: 3 additions & 2 deletions Example/Podfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use_frameworks!

platform :ios, '13.0'
platform :ios, '15.0'

target 'paystack-sdk-ios' do

Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Example/paystack-sdk-ios.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions Example/paystack-sdk-ios/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UISceneConfigurations</key>
<dict/>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UISceneConfigurations</key>
<dict/>
</dict>
</dict>
</plist>
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions Sources/PaystackSDK/Core/DisplayAudience.swift
Original file line number Diff line number Diff line change
@@ -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")
}
11 changes: 10 additions & 1 deletion Sources/PaystackSDK/Core/PaystackBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,14 +16,22 @@ 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
}

Logger.loggingEnabled = loggingEnabled

let config = PaystackConfig(apiKey: apiKey)
let config = PaystackConfig(apiKey: apiKey, displayAudience: displayAudience)
return Paystack(config: config)
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/PaystackSDK/Core/PaystackConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 3 additions & 1 deletion Sources/PaystackUI/Charge/StartCharge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }

}

Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
29 changes: 15 additions & 14 deletions Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
let remainingSeconds: Int
let onChangePaymentMethod: () -> Void

let showsOpenZapButton: Bool
@Environment(\.displayAudience)
private var displayAudience

private var formattedRemaining: String {
let minutes = max(0, remainingSeconds) / 60
Expand All @@ -29,12 +30,13 @@

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

Expand Down Expand Up @@ -78,10 +80,9 @@

private var actionButtons: some View {
VStack(spacing: .singlePadding) {
if showsOpenZapButton {
Comment thread
zaheer-paystack marked this conversation as resolved.
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)
Expand All @@ -106,20 +107,20 @@
Group {
ZapPaymentView(details: .example,
remainingSeconds: 254,
onChangePaymentMethod: {},
showsOpenZapButton: true)
.previewDisplayName("Default — Open Zap + Change payment method")
onChangePaymentMethod: {})

Check failure on line 110 in Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this closure is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=PaystackHQ_paystack-sdk-ios&issues=AaD3KR-bFjCgWWMP9gKn&open=AaD3KR-bFjCgWWMP9gKn&pullRequest=136
.environment(\.displayAudience, .customerFacing)
.previewDisplayName("Customer-facing — Open Zap, no QR")

ZapPaymentView(details: .example,
remainingSeconds: 254,
onChangePaymentMethod: {},
showsOpenZapButton: false)
.previewDisplayName("QR-only (terminal mode)")
onChangePaymentMethod: {})

Check failure on line 116 in Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this closure is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=PaystackHQ_paystack-sdk-ios&issues=AaD3KR-bFjCgWWMP9gKo&open=AaD3KR-bFjCgWWMP9gKo&pullRequest=136
.environment(\.displayAudience, .merchantFacing)
.previewDisplayName("Merchant-facing — QR only")

ZapPaymentView(details: .example,
remainingSeconds: 42,
onChangePaymentMethod: {},
showsOpenZapButton: true)
onChangePaymentMethod: {})

Check failure on line 122 in Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this closure is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=PaystackHQ_paystack-sdk-ios&issues=AaD3KR-bFjCgWWMP9gKp&open=AaD3KR-bFjCgWWMP9gKp&pullRequest=136
.environment(\.displayAudience, .merchantFacing)
.previewDisplayName("Final 60s — countdown warning colour")
}
}
Expand Down
3 changes: 1 addition & 2 deletions Sources/PaystackUI/Charge/Zap/Views/ZapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions Sources/PaystackUI/Design/UIElementVisibility.swift
Original file line number Diff line number Diff line change
@@ -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<Value>(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))
}
}
13 changes: 13 additions & 0 deletions Sources/PaystackUI/Utils/DisplayAudienceEnvironment.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
28 changes: 28 additions & 0 deletions Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

}
Loading
Loading