-
Notifications
You must be signed in to change notification settings - Fork 316
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Paywalls
: send events to Purchases
#3164
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -195,6 +198,9 @@ struct LoadedOfferingPaywallView: View { | |
@Environment(\.locale) | ||
private var locale | ||
|
||
@Environment(\.colorScheme) | ||
private var colorScheme | ||
|
||
init( | ||
offering: Offering, | ||
activelySubscribedProductIdentifiers: Set<String>, | ||
|
@@ -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: ( | ||
Comment on lines
+225
to
+227
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❤️ |
||
lastPaywall: .init(offering: offering, paywall: paywall), | ||
id: .init() | ||
)) | ||
} | ||
|
||
var body: some View { | ||
|
@@ -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: | ||
|
@@ -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() | ||
NachoSoto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
} | ||
|
||
@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: - | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
Comment on lines
21
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are now only needed for testing purposes, right? Would it suffice to have a purchases mock (spy) and pass that in, so that we can directly call the methods from purchases? Seems like we only use them to check that we're calling the right methods with the right params There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These serve several purposes:
We have a Hopefully that answers your question why we can't just use a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, that's what I was getting at, essentially for DI. I don't feel strong enough about this to continue to block this PR, but I do think there are alternative approaches worth studying, that wouldn't necessitate building a mock of all methods in I worry about us adding a different approach for testing (instead of DI of a class, DI at the closure level). It makes things more complex for a brand new maintainer. And I'd argue that the closures are harder to read and wrap your head around than just passing in a purchases object, although that bit is subjective. Alternate approaches that I think would also do the trick:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for clarifying what you meant! It does indeed lead to a simpler implementation :) Done in #3196 |
||
|
||
/// `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 | ||
|
@@ -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 { | ||
|
@@ -74,7 +82,7 @@ final class PurchaseHandler: ObservableObject { | |
throw ErrorCode.configurationError | ||
} restorePurchases: { | ||
throw ErrorCode.configurationError | ||
} | ||
} trackEvent: { _ in } | ||
} | ||
|
||
} | ||
|
@@ -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 | ||
|
@@ -116,13 +126,60 @@ extension PurchaseHandler { | |
return customerInfo | ||
} | ||
|
||
func trackPaywallView(_ eventData: PaywallEvent.Data) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm super late to this, but given that we already use "view" and "paywallView" a bunch because SwiftUI, I'd call this a Paywall Impression event There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea 👍🏻 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll make a PR renaming this in the code. I'll keep the backend event identifier as "view" unless @joshdholtz is also okay changing that. |
||
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) | ||
} | ||
} | ||
|
||
} | ||
|
@@ -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 | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -71,6 +71,9 @@ private extension LoadingPaywallView { | |
}, | ||
restorePurchases: { | ||
fatalError("Should not be able to purchase") | ||
}, | ||
trackEvent: { _ in | ||
// Ignoring events from loading paywall view | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I love that the compiler made me implement this. And realized this was the best solution. |
||
} | ||
) | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this quickly became a BIG file. Maybe we should move it to a folder and split into smaller things?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it'll only grow over time
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup 👍🏻 I'll do it next week.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also done in #3196