Skip to content

Commit

Permalink
Paywalls: send events to Purchases (#3164)
Browse files Browse the repository at this point in the history
Last part, follow up to #3160.

This now makes `PaywallView` track events through the `RevenueCat` SDK.
  • Loading branch information
NachoSoto committed Sep 14, 2023
1 parent 85f54c9 commit 346bbc7
Show file tree
Hide file tree
Showing 6 changed files with 349 additions and 15 deletions.
5 changes: 5 additions & 0 deletions RevenueCatUI/Data/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ enum Strings {
case not_displaying_paywall
case dismissing_paywall

case attempted_to_track_event_with_missing_data

}

extension Strings: CustomStringConvertible {
Expand Down Expand Up @@ -55,6 +57,9 @@ extension Strings: CustomStringConvertible {

case .dismissing_paywall:
return "Dismissing PaywallView"

case .attempted_to_track_event_with_missing_data:
return "Attempted to track event with missing data"
}
}

Expand Down
24 changes: 14 additions & 10 deletions RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ internal enum TestData {
colors: .init(light: Self.lightColors, dark: Self.darkColors)
),
localization: Self.localization1,
assetBaseURL: Self.paywallAssetBaseURL
assetBaseURL: Self.paywallAssetBaseURL,
revision: 5
)

static let offeringWithIntroOffer = Offering(
Expand Down Expand Up @@ -560,19 +561,19 @@ extension PurchaseHandler {
)
} restorePurchases: {
return TestData.customerInfo
} trackEvent: { event in
Logger.debug("Tracking event: \(event)")
}
}

static func cancelling() -> Self {
return self.init { _ in
return (
transaction: nil,
customerInfo: TestData.customerInfo,
userCancelled: true
)
} restorePurchases: {
return TestData.customerInfo
}
return .mock()
.map { block in {
var result = try await block($0)
result.userCancelled = true
return result
}
} restore: { $0 }
}

/// Creates a copy of this `PurchaseHandler` with a delay.
Expand All @@ -589,8 +590,11 @@ extension PurchaseHandler {
}
}
}

}

// MARK: -

extension PaywallColor: ExpressibleByStringLiteral {

/// Creates a `PaywallColor` with a string literal
Expand Down
53 changes: 53 additions & 0 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ struct LoadedOfferingPaywallView: View {
private let mode: PaywallViewMode
private let fonts: PaywallFontProvider

@State
private var session: (lastPaywall: DisplayedPaywall, id: PaywallEvent.SessionID)

@StateObject
private var introEligibility: IntroEligibilityViewModel
@ObservedObject
Expand All @@ -195,6 +198,9 @@ struct LoadedOfferingPaywallView: View {
@Environment(\.locale)
private var locale

@Environment(\.colorScheme)
private var colorScheme

init(
offering: Offering,
activelySubscribedProductIdentifiers: Set<String>,
Expand All @@ -215,6 +221,13 @@ struct LoadedOfferingPaywallView: View {
wrappedValue: .init(introEligibilityChecker: introEligibility)
)
self._purchaseHandler = .init(initialValue: purchaseHandler)

// Each `PaywallView` impression gets its own session.
// See also `updateSessionIfNeeded`.
self._session = .init(initialValue: (
lastPaywall: .init(offering: offering, paywall: paywall),
id: .init()
))
}

var body: some View {
Expand All @@ -233,6 +246,8 @@ struct LoadedOfferingPaywallView: View {
.preference(key: RestoredCustomerInfoPreferenceKey.self,
value: self.purchaseHandler.restoredCustomerInfo)
.disabled(self.purchaseHandler.actionInProgress)
.onAppear { self.purchaseHandler.trackPaywallView(self.eventData) }
.onDisappear { self.purchaseHandler.trackPaywallClose(self.eventData) }

switch self.mode {
case .fullScreen:
Expand All @@ -245,6 +260,44 @@ struct LoadedOfferingPaywallView: View {
}
}

private var eventData: PaywallEvent.Data {
self.updateSessionIfNeeded()

return .init(
offering: self.offering,
paywall: self.paywall,
sessionID: self.session.id,
displayMode: self.mode,
locale: .current,
darkMode: self.colorScheme == .dark
)
}

private func updateSessionIfNeeded() {
let newPaywall: DisplayedPaywall = .init(offering: self.offering, paywall: self.paywall)
guard self.session.lastPaywall != newPaywall else { return }

self.session.lastPaywall = newPaywall
self.session.id = .init()
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
private extension LoadedOfferingPaywallView {

struct DisplayedPaywall: Equatable {
var offeringIdentifier: String
var paywallTemplate: String
var revision: Int

init(offering: Offering, paywall: PaywallData) {
self.offeringIdentifier = offering.identifier
self.paywallTemplate = paywall.templateName
self.revision = paywall.revision
}
}

}

// MARK: -
Expand Down
81 changes: 76 additions & 5 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,19 @@ import RevenueCat
import StoreKit
import SwiftUI

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *)
@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
final class PurchaseHandler: ObservableObject {

typealias PurchaseBlock = @Sendable (Package) async throws -> PurchaseResultData
typealias RestoreBlock = @Sendable () async throws -> CustomerInfo
typealias TrackEventBlock = @Sendable (PaywallEvent) async -> Void

/// `false` if this `PurchaseHandler` is not backend by a configured `Purchases`instance.
let isConfigured: Bool

private let purchaseBlock: PurchaseBlock
private let restoreBlock: RestoreBlock
private let trackEventBlock: TrackEventBlock

/// Whether a purchase or restore is currently in progress
@Published
Expand All @@ -47,22 +49,28 @@ final class PurchaseHandler: ObservableObject {
@Published
fileprivate(set) var restoredCustomerInfo: CustomerInfo?

private var eventData: PaywallEvent.Data?

convenience init(purchases: Purchases = .shared) {
self.init(isConfigured: true) { package in
return try await purchases.purchase(package: package)
} restorePurchases: {
return try await purchases.restorePurchases()
} trackEvent: { event in
await purchases.track(paywallEvent: event)
}
}

init(
isConfigured: Bool = true,
purchase: @escaping PurchaseBlock,
restorePurchases: @escaping RestoreBlock
restorePurchases: @escaping RestoreBlock,
trackEvent: @escaping TrackEventBlock
) {
self.isConfigured = isConfigured
self.purchaseBlock = purchase
self.restoreBlock = restorePurchases
self.trackEventBlock = trackEvent
}

static func `default`() -> Self {
Expand All @@ -74,7 +82,7 @@ final class PurchaseHandler: ObservableObject {
throw ErrorCode.configurationError
} restorePurchases: {
throw ErrorCode.configurationError
}
} trackEvent: { _ in }
}

}
Expand All @@ -91,7 +99,9 @@ extension PurchaseHandler {

let result = try await self.purchaseBlock(package)

if !result.userCancelled {
if result.userCancelled {
self.trackCancelledPurchase()
} else {
withAnimation(Constants.defaultAnimation) {
self.purchased = true
self.purchasedCustomerInfo = result.customerInfo
Expand All @@ -116,13 +126,60 @@ extension PurchaseHandler {
return customerInfo
}

func trackPaywallView(_ eventData: PaywallEvent.Data) {
self.eventData = eventData
self.track(.view(eventData))
}

func trackPaywallClose(_ eventData: PaywallEvent.Data) {
self.track(.close(eventData))
}

fileprivate func trackCancelledPurchase() {
guard let data = self.eventData else {
Logger.warning(Strings.attempted_to_track_event_with_missing_data)
return
}

self.track(.cancel(data.withCurrentDate()))
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
extension PurchaseHandler {

/// Creates a copy of this `PurchaseHandler` wrapping the purchase and restore blocks.
func map(
purchase: @escaping (@escaping PurchaseBlock) -> PurchaseBlock,
restore: @escaping (@escaping RestoreBlock) -> RestoreBlock
) -> Self {
return .init(purchase: purchase(self.purchaseBlock),
restorePurchases: restore(self.restoreBlock))
restorePurchases: restore(self.restoreBlock),
trackEvent: self.trackEventBlock)
}

func map(
trackEvent: @escaping (@escaping TrackEventBlock) -> TrackEventBlock
) -> Self {
return .init(
purchase: self.purchaseBlock,
restorePurchases: self.restoreBlock,
trackEvent: trackEvent(self.trackEventBlock)
)
}

}

// MARK: - Private

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
private extension PurchaseHandler {

func track(_ event: PaywallEvent) {
Task.detached(priority: .background) { [block = self.trackEventBlock] in
await block(event)
}
}

}
Expand Down Expand Up @@ -150,3 +207,17 @@ struct RestoredCustomerInfoPreferenceKey: PreferenceKey {
}

}

// MARK: -

private extension PaywallEvent.Data {

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
func withCurrentDate() -> Self {
var copy = self
copy.date = .now

return copy
}

}
3 changes: 3 additions & 0 deletions RevenueCatUI/Views/LoadingPaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ private extension LoadingPaywallView {
},
restorePurchases: {
fatalError("Should not be able to purchase")
},
trackEvent: { _ in
// Ignoring events from loading paywall view
}
)

Expand Down
Loading

0 comments on commit 346bbc7

Please sign in to comment.